├── .gitattributes ├── .gitignore ├── .dockerignore ├── tts_core ├── src │ ├── lib.rs │ ├── opt_ext.rs │ ├── analytics.rs │ ├── macros.rs │ ├── constants.rs │ ├── traits.rs │ ├── database_models.rs │ ├── database.rs │ └── common.rs └── Cargo.toml ├── .cargo └── config.toml ├── clippy.toml ├── tts_migrations ├── Cargo.toml └── src │ └── lib.rs ├── Dockerfile ├── tts_events ├── Cargo.toml └── src │ ├── other.rs │ ├── channel.rs │ ├── voice_state.rs │ ├── guild.rs │ ├── member.rs │ ├── lib.rs │ ├── ready.rs │ ├── message.rs │ └── message │ └── tts.rs ├── tts_tasks ├── Cargo.toml └── src │ ├── lib.rs │ ├── analytics.rs │ ├── bot_list_updater.rs │ ├── web_updater.rs │ └── logging.rs ├── config-docker.toml ├── config-selfhost.toml ├── tts_commands ├── Cargo.toml └── src │ ├── settings │ ├── owner.rs │ ├── voice_paginator.rs │ └── setup.rs │ ├── lib.rs │ ├── help.rs │ ├── premium.rs │ ├── other.rs │ ├── main_.rs │ └── owner.rs ├── .github └── workflows │ └── docker.yml ├── docker-compose-example.yml ├── README.md ├── Cargo.toml └── src ├── startup.rs └── main.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | * eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.hidden 3 | docker-compose.yml 4 | /config.toml 5 | /target 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | .github 3 | .gitignore 4 | .gitattributes 5 | 6 | # These files shouldn't be sent into the container as it causes a rebuild 7 | # on simple config changes/development 8 | Dockerfile 9 | docker-compose.* 10 | config.toml 11 | -------------------------------------------------------------------------------- /tts_core/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(async_fn_in_trait)] 2 | 3 | pub mod analytics; 4 | pub mod common; 5 | pub mod constants; 6 | pub mod database; 7 | pub mod database_models; 8 | pub mod errors; 9 | pub mod macros; 10 | pub mod opt_ext; 11 | pub mod structs; 12 | pub mod traits; 13 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-unknown-linux-gnu] 2 | rustflags = [ 3 | '--cfg=tokio_unstable', 4 | "-Ctarget-cpu=x86-64-v3", 5 | "-Clink-arg=-fuse-ld=mold", 6 | ] 7 | 8 | [target.aarch64-unknown-linux-gnu] 9 | rustflags = ['--cfg=tokio_unstable', "-Clink-arg=-fuse-ld=mold"] 10 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | avoid-breaking-exported-api = false 2 | disallowed-methods = [ 3 | "std::option::Option::map_or", 4 | "std::option::Option::map_or_else", 5 | "serenity::builder::create_message::CreateMessage::embed", 6 | "songbird::Songbird::remove", # Use `Data::leave_vc` 7 | ] 8 | -------------------------------------------------------------------------------- /tts_migrations/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tts_migrations" 3 | version = "0.1.0" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | 7 | [dependencies] 8 | toml = "0.8" 9 | 10 | sqlx.workspace = true 11 | anyhow.workspace = true 12 | 13 | tts_core = { path = "../tts_core" } 14 | 15 | [lints] 16 | workspace = true 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rustlang/rust:nightly as builder 2 | 3 | WORKDIR /bot 4 | 5 | RUN apt-get update && apt-get install -y cmake mold && apt-get clean 6 | 7 | COPY . . 8 | RUN cargo build --release 9 | 10 | # Now make the runtime container 11 | FROM debian:trixie-slim 12 | 13 | RUN apt-get update && apt-get upgrade -y && apt-get install -y ca-certificates mold && rm -rf /var/lib/apt/lists/* 14 | 15 | COPY --from=builder /bot/target/release/tts_bot /usr/local/bin/discord_tts_bot 16 | 17 | CMD ["/usr/local/bin/discord_tts_bot"] 18 | -------------------------------------------------------------------------------- /tts_events/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tts_events" 3 | version = "0.1.0" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | 7 | [dependencies] 8 | libc = "0.2.152" 9 | 10 | poise.workspace = true 11 | tokio.workspace = true 12 | regex.workspace = true 13 | anyhow.workspace = true 14 | dashmap.workspace = true 15 | tracing.workspace = true 16 | reqwest.workspace = true 17 | aformat.workspace = true 18 | serenity.workspace = true 19 | songbird.workspace = true 20 | 21 | tts_core = { path = "../tts_core" } 22 | tts_tasks = { path = "../tts_tasks" } 23 | 24 | [lints] 25 | workspace = true 26 | -------------------------------------------------------------------------------- /tts_events/src/other.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use poise::serenity_prelude as serenity; 4 | 5 | use tts_core::{ 6 | errors, 7 | structs::{Data, Result}, 8 | }; 9 | 10 | pub fn resume(ctx: &serenity::Context) { 11 | tracing::info!("Shard {} has resumed", ctx.shard_id); 12 | 13 | let data = ctx.data_ref::(); 14 | data.analytics.log(Cow::Borrowed("resumed"), false); 15 | } 16 | 17 | pub async fn interaction_create( 18 | ctx: &serenity::Context, 19 | interaction: &serenity::Interaction, 20 | ) -> Result<()> { 21 | errors::interaction_create(ctx, interaction).await 22 | } 23 | -------------------------------------------------------------------------------- /tts_tasks/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tts_tasks" 3 | version = "0.1.0" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | 7 | [dependencies] 8 | serde_json = "1.0.111" 9 | tracing-subscriber = { version = "0.3.19", features = ["parking_lot"] } 10 | 11 | sqlx.workspace = true 12 | tokio.workspace = true 13 | serde.workspace = true 14 | anyhow.workspace = true 15 | tracing.workspace = true 16 | reqwest.workspace = true 17 | aformat.workspace = true 18 | serenity.workspace = true 19 | itertools.workspace = true 20 | parking_lot.workspace = true 21 | 22 | tts_core = { path = "../tts_core" } 23 | 24 | [lints] 25 | workspace = true 26 | -------------------------------------------------------------------------------- /config-docker.toml: -------------------------------------------------------------------------------- 1 | [Main] 2 | log_level = 'info' 3 | tts_service = 'https://localhost:20310' 4 | #main_server_invite = 'https://discord.gg/example' 5 | #announcements_channel = id here 6 | #invite_channel = id here 7 | #main_server = id here 8 | #ofs_role = id here 9 | #token = 10 | 11 | [PostgreSQL-Info] 12 | database = 'tts' 13 | password = 'tts_password' 14 | host = 'localhost' 15 | user = 'tts' 16 | 17 | [Webhook-Info] 18 | # Each URL will look like 'https://discord.com/api/webhooks/830137192985788457/nCrFLCz-2tJRFUoBrFx1nN9cvUZdhdW0860ek0zNosf0DfCaMTbyM_oFdf9RidC_mcPp' 19 | #logs = 20 | #errors = 21 | #dm_logs = 22 | #servers = 23 | #analytics = 24 | #suggestions = 25 | -------------------------------------------------------------------------------- /config-selfhost.toml: -------------------------------------------------------------------------------- 1 | [Main] 2 | log_level = 'info' 3 | #tts_service = instance of https://github.com/GnomedDev/tts-service 4 | #main_server_invite = 'https://discord.gg/example' 5 | #announcements_channel = id here 6 | #invite_channel = id here 7 | #main_server = id here 8 | #ofs_role = = id here 9 | #token = 10 | 11 | [PostgreSQL-Info] 12 | #database = 13 | #password = 14 | #host = 15 | #user = 16 | 17 | [Webhook-Info] 18 | # Each URL will look like 'https://discord.com/api/webhooks/830137192985788457/nCrFLCz-2tJRFUoBrFx1nN9cvUZdhdW0860ek0zNosf0DfCaMTbyM_oFdf9RidC_mcPp' 19 | #logs = 20 | #errors = 21 | #dm_logs = 22 | #servers = 23 | #analytics = 24 | #suggestions = 25 | -------------------------------------------------------------------------------- /tts_core/src/opt_ext.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | #[cold] 4 | fn create_err(line: u32, file: &str) -> anyhow::Error { 5 | anyhow::anyhow!("Unexpected None value on line {line} in {file}",) 6 | } 7 | 8 | pub trait OptionTryUnwrap { 9 | fn try_unwrap(self) -> Result; 10 | } 11 | 12 | impl OptionTryUnwrap for Option { 13 | #[track_caller] 14 | fn try_unwrap(self) -> Result { 15 | match self { 16 | Some(v) => Ok(v), 17 | None => Err({ 18 | let location = std::panic::Location::caller(); 19 | create_err(location.line(), location.file()) 20 | }), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tts_commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tts_commands" 3 | version = "0.1.0" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | 7 | [dependencies] 8 | indexmap = "2" 9 | strsim = "0.11" 10 | num-format = "0.4" 11 | futures-channel = "0.3.31" 12 | uuid = { version = "1.17.0", features = ["v7"] } 13 | 14 | sqlx.workspace = true 15 | tokio.workspace = true 16 | poise.workspace = true 17 | anyhow.workspace = true 18 | sysinfo.workspace = true 19 | tracing.workspace = true 20 | aformat.workspace = true 21 | serenity.workspace = true 22 | typesize.workspace = true 23 | songbird.workspace = true 24 | arrayvec.workspace = true 25 | 26 | tts_core = { path = "../tts_core" } 27 | 28 | [lints] 29 | workspace = true 30 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v1 15 | - 16 | name: Login to DockerHub 17 | uses: docker/login-action@v1 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | - 22 | name: Build and push 23 | uses: docker/build-push-action@v2 24 | with: 25 | push: true 26 | platforms: linux/amd64,linux/arm64 27 | tags: | 28 | gnomeddev/discord-tts-bot:latest 29 | gnomeddev/discord-tts-bot:${{ github.sha }} 30 | -------------------------------------------------------------------------------- /tts_tasks/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(async_fn_in_trait)] 2 | #![feature(never_type, trait_alias)] 3 | 4 | mod analytics; 5 | pub mod bot_list_updater; 6 | pub mod logging; 7 | pub mod web_updater; 8 | 9 | pub trait Looper { 10 | const NAME: &'static str; 11 | const MILLIS: u64; 12 | 13 | type Error: std::fmt::Debug; 14 | 15 | async fn loop_func(&self) -> Result<(), Self::Error>; 16 | async fn start(self) 17 | where 18 | Self: Sized, 19 | { 20 | tracing::info!("{}: Started background task", Self::NAME); 21 | let mut interval = tokio::time::interval(std::time::Duration::from_millis(Self::MILLIS)); 22 | loop { 23 | interval.tick().await; 24 | if let Err(err) = self.loop_func().await { 25 | tracing::error!("{} Error: {:?}", Self::NAME, err); 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docker-compose-example.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bot: 3 | image: Discord-TTS/Bot 4 | volumes: 5 | - type: bind 6 | source: ./config.toml 7 | target: /config.toml 8 | 9 | depends_on: [database, tts-service] 10 | network_mode: "host" 11 | database: 12 | image: postgres:13 13 | ports: [5432:5432] 14 | environment: 15 | POSTGRES_USER: tts 16 | POSTGRES_PASSWORD: tts_password 17 | tts-service: 18 | image: gnomeddev/tts-service 19 | volumes: 20 | - type: bind 21 | source: ${GOOGLE_APPLICATION_CREDENTIALS} 22 | target: /gcp.json 23 | environment: 24 | - IPV6_BLOCK 25 | - LOG_LEVEL=INFO 26 | - BIND_ADDR=0.0.0.0:20310 27 | - GOOGLE_APPLICATION_CREDENTIALS=/gcp.json 28 | network_mode: "host" 29 | expose: [20310] 30 | -------------------------------------------------------------------------------- /tts_events/src/channel.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude as serenity; 2 | 3 | use tts_core::structs::{Data, Result}; 4 | 5 | async fn guild_call_channel_id( 6 | songbird: &songbird::Songbird, 7 | guild_id: serenity::GuildId, 8 | ) -> Option { 9 | songbird 10 | .get(guild_id)? 11 | .lock() 12 | .await 13 | .current_channel() 14 | .map(|c| serenity::ChannelId::new(c.get())) 15 | } 16 | 17 | // Check if the channel the bot was in was deleted. 18 | pub async fn handle_delete( 19 | ctx: &serenity::Context, 20 | channel: &serenity::GuildChannel, 21 | ) -> Result<()> { 22 | let data = ctx.data_ref::(); 23 | let guild_id = channel.base.guild_id; 24 | 25 | let call_channel_id = guild_call_channel_id(&data.songbird, guild_id).await; 26 | if call_channel_id == Some(channel.id) { 27 | // Ignore errors from leaving the channel, probably already left. 28 | let _ = data.leave_vc(guild_id).await; 29 | } 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /tts_core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tts_core" 3 | version = "0.1.0" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | 7 | [dependencies] 8 | rand = "0.9" 9 | sha2 = "0.10" 10 | linkify = "0.10" 11 | bitflags = "2.4.1" 12 | strum_macros = "0.27" 13 | chrono = { version = "0.4.38", default-features = false } 14 | bool_to_bitflags = { version = "0.1", features = ["typesize"] } 15 | 16 | sqlx.workspace = true 17 | regex.workspace = true 18 | poise.workspace = true 19 | serde.workspace = true 20 | tokio.workspace = true 21 | anyhow.workspace = true 22 | aformat.workspace = true 23 | sysinfo.workspace = true 24 | tracing.workspace = true 25 | dashmap.workspace = true 26 | reqwest.workspace = true 27 | arrayvec.workspace = true 28 | typesize.workspace = true 29 | songbird.workspace = true 30 | serenity.workspace = true 31 | mini-moka.workspace = true 32 | itertools.workspace = true 33 | parking_lot.workspace = true 34 | 35 | [lints] 36 | workspace = true 37 | 38 | [package.metadata.cargo-machete] 39 | ignored = [ 40 | "bitflags", # Used in `bool_to_bitflags` 41 | ] 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TTS Bot - Rust Rewrite 2 | 3 | Text to speech Discord Bot using Serenity, Songbird, and Poise 4 | 5 | ## Setup Guide: 6 | ### Easy (Public Bot): 7 | - Invite the bot with [this invite](https://bit.ly/TTSBotSlash) 8 | - Run -setup #text_channel_to_read_from 9 | - Run -join in that text channel, while being in a voice channel 10 | - Type normally in the setup text channel! 11 | 12 | ### Normal (Docker): 13 | - Make sure docker, docker-compose, and git are installed 14 | - Run `git clone https://github.com/Discord-TTS/Bot.git` 15 | - Rename `config-docker.toml` to `config.toml` and fill it out 16 | - Rename `docker-compose-example.yml` to `docker-compose.yml` and fill it out 17 | - Rename `Dockerfile-prod` OR `Dockerfile-dev` to `Dockerfile` 18 | (prod takes longer to build, dev is less efficient to run) 19 | 20 | - Build and run the docker containers with `docker-compose up --build -d` 21 | - Check the terminal output with `docker-compose logs bot` 22 | - Now the bot is running in the container, and you can use it! 23 | 24 | ### Hard (Self Host): 25 | - Make sure rust nightly, cargo, git, postgresql, and ffmpeg are installed 26 | - Run `git clone https://github.com/Discord-TTS/Bot.git` 27 | - Rename `config-selfhost.toml` to `config.toml` and fill it out 28 | 29 | - Run `cargo build --release` 30 | - Run the produced exe file in the `/target/release` folder 31 | - Now the bot is running in your terminal, and you can use it! 32 | -------------------------------------------------------------------------------- /tts_tasks/src/analytics.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use sqlx::Connection; 4 | 5 | use tts_core::analytics; 6 | 7 | impl crate::Looper for Arc { 8 | const NAME: &'static str = "Analytics"; 9 | const MILLIS: u64 = 5000; 10 | 11 | type Error = anyhow::Error; 12 | async fn loop_func(&self) -> anyhow::Result<()> { 13 | let log_buffer = self.log_buffer.clone(); 14 | self.log_buffer.clear(); 15 | 16 | let mut conn = self.pool.acquire().await?; 17 | conn.transaction(move |transaction| { 18 | Box::pin(async { 19 | for ((event, kind), count) in log_buffer { 20 | let query = sqlx::query( 21 | " 22 | INSERT INTO analytics(event, is_command, count) 23 | VALUES($1, $2, $3) 24 | ON CONFLICT ON CONSTRAINT analytics_pkey 25 | DO UPDATE SET count = analytics.count + EXCLUDED.count 26 | ;", 27 | ); 28 | 29 | query 30 | .bind(event) 31 | .bind(kind == analytics::EventType::Command) 32 | .bind(count) 33 | .execute(&mut **transaction) 34 | .await?; 35 | } 36 | 37 | Ok(()) 38 | }) 39 | }) 40 | .await 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tts_core/src/analytics.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, future::Future, pin::Pin}; 2 | 3 | use dashmap::DashMap; 4 | 5 | use serenity::futures; 6 | 7 | use crate::{bool_enum, structs::Context}; 8 | 9 | bool_enum!(EventType(Normal | Command)); 10 | 11 | pub struct Handler { 12 | pub log_buffer: DashMap<(Cow<'static, str>, EventType), i32>, 13 | pub pool: sqlx::PgPool, 14 | } 15 | 16 | impl Handler { 17 | #[must_use] 18 | pub fn new(pool: sqlx::PgPool) -> Self { 19 | Self { 20 | pool, 21 | log_buffer: DashMap::new(), 22 | } 23 | } 24 | 25 | pub fn log(&self, event: Cow<'static, str>, kind: impl Into) { 26 | let key = (event, kind.into()); 27 | 28 | let count = (*self.log_buffer.entry(key.clone()).or_insert(0)) + 1; 29 | self.log_buffer.insert(key, count); 30 | } 31 | } 32 | 33 | #[must_use] 34 | pub fn pre_command(ctx: Context<'_>) -> Pin + Send + '_>> { 35 | let analytics_handler = &ctx.data().analytics; 36 | 37 | analytics_handler.log(ctx.command().qualified_name.clone(), true); 38 | analytics_handler.log( 39 | Cow::Borrowed(match ctx { 40 | poise::Context::Prefix(_) => "command", 41 | poise::Context::Application(ctx) => match ctx.interaction_type { 42 | poise::CommandInteractionType::Autocomplete => "autocomplete", 43 | poise::CommandInteractionType::Command => "slash_command", 44 | }, 45 | }), 46 | false, 47 | ); 48 | 49 | Box::pin(futures::future::always_ready(|| ())) 50 | } 51 | -------------------------------------------------------------------------------- /tts_core/src/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! require { 3 | ($to_check:expr) => { 4 | require!($to_check, ()) 5 | }; 6 | ($to_check:expr, $ret:expr) => { 7 | if let Some(to_check) = $to_check { 8 | to_check 9 | } else { 10 | return $ret; 11 | } 12 | }; 13 | } 14 | 15 | #[macro_export] 16 | macro_rules! require_guild { 17 | ($ctx:expr) => { 18 | require_guild!($ctx, Ok(())) 19 | }; 20 | ($ctx:expr, $ret:expr) => { 21 | $crate::require!($ctx.guild(), { 22 | ::tracing::warn!( 23 | "Guild {} not cached in {} command!", 24 | $ctx.guild_id().unwrap(), 25 | $ctx.command().qualified_name 26 | ); 27 | $ret 28 | }) 29 | }; 30 | } 31 | 32 | #[macro_export] 33 | macro_rules! bool_enum { 34 | ($name:ident($true_value:ident | $false_value:ident)) => { 35 | #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] 36 | pub enum $name { 37 | $true_value, 38 | $false_value, 39 | } 40 | 41 | impl From<$name> for bool { 42 | fn from(value: $name) -> bool { 43 | value == $name::$true_value 44 | } 45 | } 46 | 47 | impl From for $name { 48 | fn from(value: bool) -> Self { 49 | if value { 50 | Self::$true_value 51 | } else { 52 | Self::$false_value 53 | } 54 | } 55 | } 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /tts_events/src/voice_state.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude as serenity; 2 | 3 | use tts_core::{ 4 | opt_ext::OptionTryUnwrap, 5 | structs::{Data, Result}, 6 | }; 7 | 8 | pub async fn handle( 9 | ctx: &serenity::Context, 10 | old: Option<&serenity::VoiceState>, 11 | new: &serenity::VoiceState, 12 | ) -> Result<()> { 13 | // User left vc 14 | let Some(old) = old else { return Ok(()) }; 15 | 16 | let data = ctx.data_ref::(); 17 | 18 | // Bot is in vc on server 19 | let guild_id = new.guild_id.try_unwrap()?; 20 | if data.songbird.get(guild_id).is_none() { 21 | return Ok(()); 22 | } 23 | 24 | // Check if the bot is leaving 25 | let bot_id = ctx.cache.current_user().id; 26 | let leave_vc = match &new.member { 27 | // songbird does not clean up state on VC disconnections, so we have to do it here 28 | Some(member) if member.user.id == bot_id => true, 29 | Some(_) => check_is_lonely(ctx, bot_id, guild_id, old)?, 30 | None => false, 31 | }; 32 | 33 | if leave_vc { 34 | data.leave_vc(guild_id).await?; 35 | } 36 | 37 | Ok(()) 38 | } 39 | 40 | /// If (on leave) the bot should also leave as it is alone 41 | fn check_is_lonely( 42 | ctx: &serenity::Context, 43 | bot_id: serenity::UserId, 44 | guild_id: serenity::GuildId, 45 | old: &serenity::VoiceState, 46 | ) -> Result { 47 | let channel_id = old.channel_id.try_unwrap()?; 48 | let guild = ctx.cache.guild(guild_id).try_unwrap()?; 49 | let mut channel_members = guild.members.iter().filter(|m| { 50 | guild 51 | .voice_states 52 | .get(&m.user.id) 53 | .is_some_and(|v| v.channel_id == Some(channel_id)) 54 | }); 55 | 56 | // Bot is in the voice channel being left from 57 | if channel_members.clone().all(|m| m.user.id != bot_id) { 58 | return Ok(false); 59 | } 60 | 61 | // All the users in the vc are now bots 62 | if channel_members.any(|m| !m.user.bot()) { 63 | return Ok(false); 64 | } 65 | 66 | Ok(true) 67 | } 68 | -------------------------------------------------------------------------------- /tts_commands/src/settings/owner.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::Ordering; 2 | 3 | use aformat::aformat; 4 | use poise::serenity_prelude as serenity; 5 | 6 | use tts_core::structs::{CommandResult, Context}; 7 | 8 | /// Owner only: used to block a user from dms 9 | #[poise::command( 10 | prefix_command, 11 | category = "Settings", 12 | owners_only, 13 | hide_in_help, 14 | required_bot_permissions = "SEND_MESSAGES" 15 | )] 16 | pub async fn block(ctx: Context<'_>, user: serenity::UserId, value: bool) -> CommandResult { 17 | ctx.data() 18 | .userinfo_db 19 | .set_one(user.into(), "dm_blocked", &value) 20 | .await?; 21 | 22 | ctx.say("Done!").await?; 23 | Ok(()) 24 | } 25 | 26 | /// Owner only: used to block a user from the bot 27 | #[poise::command( 28 | prefix_command, 29 | category = "Settings", 30 | owners_only, 31 | hide_in_help, 32 | required_bot_permissions = "SEND_MESSAGES" 33 | )] 34 | pub async fn bot_ban(ctx: Context<'_>, user: serenity::UserId, value: bool) -> CommandResult { 35 | let user_id = user.into(); 36 | let userinfo_db = &ctx.data().userinfo_db; 37 | 38 | userinfo_db.set_one(user_id, "bot_banned", &value).await?; 39 | if value { 40 | userinfo_db.set_one(user_id, "dm_blocked", &true).await?; 41 | } 42 | 43 | let msg = aformat!("Set bot ban status for user {user} to `{value}`."); 44 | ctx.say(msg.as_str()).await?; 45 | 46 | Ok(()) 47 | } 48 | 49 | /// Owner only: Enables or disables the gTTS voice mode 50 | #[poise::command( 51 | prefix_command, 52 | category = "Settings", 53 | owners_only, 54 | hide_in_help, 55 | required_bot_permissions = "SEND_MESSAGES" 56 | )] 57 | pub async fn gtts_disabled(ctx: Context<'_>, value: bool) -> CommandResult { 58 | let data = ctx.data(); 59 | if data.config.gtts_disabled.swap(value, Ordering::Relaxed) == value { 60 | ctx.say("It's already set that way, silly.").await?; 61 | return Ok(()); 62 | } 63 | 64 | let msg = if value { 65 | "Disabled gTTS globally, womp womp" 66 | } else { 67 | "Re-enabled gTTS globally, yippee!\nMake sure to check the config file though." 68 | }; 69 | 70 | ctx.say(msg).await?; 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tts_bot" 3 | version = "0.1.0" 4 | edition = "2024" 5 | rust-version = "1.88" 6 | 7 | [workspace] 8 | members = [ 9 | "tts_core", 10 | "tts_commands", 11 | "tts_events", 12 | "tts_tasks", 13 | "tts_migrations", 14 | ] 15 | 16 | [profile.release] 17 | lto = "thin" 18 | panic = "abort" 19 | debug = 2 20 | 21 | [profile.dev.package.syn] 22 | opt-level = 3 23 | 24 | [lints] 25 | workspace = true 26 | 27 | [dependencies] 28 | const_format = "0.2" 29 | jemallocator = "0.5.4" 30 | small-fixed-array = { version = "0.4.5", features = [ 31 | "nightly", 32 | "to-arraystring", 33 | ] } 34 | 35 | serde.workspace = true 36 | tokio.workspace = true 37 | poise.workspace = true 38 | anyhow.workspace = true 39 | sysinfo.workspace = true 40 | tracing.workspace = true 41 | reqwest.workspace = true 42 | dashmap.workspace = true 43 | songbird.workspace = true 44 | mini-moka.workspace = true 45 | parking_lot.workspace = true 46 | 47 | tts_core = { path = "tts_core" } 48 | tts_tasks = { path = "tts_tasks" } 49 | tts_events = { path = "tts_events" } 50 | tts_commands = { path = "tts_commands" } 51 | tts_migrations = { path = "tts_migrations" } 52 | 53 | [dependencies.symphonia] 54 | features = ["mp3", "ogg", "wav", "pcm"] 55 | default-features = false 56 | version = "0.5.3" 57 | 58 | [workspace.dependencies] 59 | regex = "1" 60 | anyhow = "1" 61 | serde = "1.0.209" 62 | tracing = "0.1" 63 | sysinfo = "0.36" 64 | aformat = "0.1.3" 65 | itertools = "0.14" 66 | arrayvec = "0.7.6" 67 | parking_lot = "0.12" 68 | mini-moka = { version = "0.10.3", features = ["sync"] } 69 | # TODO: Remove `dashmap` once mini_moka releases a breaking version with dashmap 6. 70 | typesize = { version = "0.1.9", features = ["arrayvec", "dashmap", "details"] } 71 | 72 | [workspace.dependencies.sqlx] 73 | version = "0.8.1" 74 | default-features = false 75 | features = ["macros", "postgres", "runtime-tokio-rustls"] 76 | 77 | [workspace.dependencies.reqwest] 78 | version = "0.12.7" 79 | default-features = false 80 | features = ["rustls-tls"] 81 | 82 | [workspace.dependencies.tokio] 83 | version = "1.39.3" 84 | features = ["rt-multi-thread", "signal", "parking_lot"] 85 | 86 | [workspace.dependencies.dashmap] 87 | version = "6.1.0" 88 | default-features = false 89 | 90 | [workspace.dependencies.serenity] 91 | git = "https://github.com/serenity-rs/serenity" 92 | default-features = false 93 | branch = "next" 94 | features = [ 95 | "typesize", 96 | "temp_cache", 97 | "tokio_task_builder", 98 | "transport_compression_zstd", 99 | ] 100 | 101 | [workspace.dependencies.poise] 102 | git = "https://github.com/serenity-rs/poise" 103 | features = ["cache"] 104 | branch = "serenity-next" 105 | 106 | [workspace.dependencies.songbird] 107 | git = "https://github.com/serenity-rs/songbird" 108 | features = ["builtin-queue"] 109 | branch = "serenity-next" 110 | 111 | [workspace.lints.rust] 112 | rust_2018_idioms = "warn" 113 | missing_copy_implementations = "warn" 114 | noop_method_call = "warn" 115 | 116 | [workspace.lints.clippy] 117 | pedantic = { level = "warn", priority = -1 } 118 | cast_sign_loss = "allow" 119 | cast_possible_wrap = "allow" 120 | cast_lossless = "allow" 121 | cast_possible_truncation = "allow" 122 | unreadable_literal = "allow" 123 | wildcard_imports = "allow" 124 | too_many_lines = "allow" 125 | similar_names = "allow" 126 | missing_panics_doc = "allow" 127 | missing_errors_doc = "allow" 128 | map_unwrap_or = "allow" 129 | 130 | [package.metadata.cargo-machete] 131 | ignored = [ 132 | "const_format", # Used by database initialisation in macro 133 | "symphonia", # Enabled for the features 134 | ] 135 | -------------------------------------------------------------------------------- /tts_tasks/src/bot_list_updater.rs: -------------------------------------------------------------------------------- 1 | use std::{num::NonZeroU16, sync::Arc}; 2 | 3 | use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue}; 4 | use serde_json::{json, to_vec}; 5 | 6 | use self::serenity::UserId; 7 | use serenity::all as serenity; 8 | 9 | use tts_core::structs::{BotListTokens, Result}; 10 | 11 | pub struct BotListUpdater { 12 | cache: Arc, 13 | reqwest: reqwest::Client, 14 | tokens: BotListTokens, 15 | } 16 | 17 | struct BotListReq { 18 | url: String, 19 | body: Vec, 20 | token: HeaderValue, 21 | } 22 | 23 | impl BotListUpdater { 24 | #[must_use] 25 | pub fn new( 26 | reqwest: reqwest::Client, 27 | cache: Arc, 28 | tokens: BotListTokens, 29 | ) -> Self { 30 | Self { 31 | cache, 32 | reqwest, 33 | tokens, 34 | } 35 | } 36 | 37 | fn top_gg_data( 38 | &self, 39 | bot_id: UserId, 40 | guild_count: usize, 41 | shard_count: NonZeroU16, 42 | ) -> BotListReq { 43 | BotListReq { 44 | url: format!("https://top.gg/api/bots/{bot_id}/stats"), 45 | token: HeaderValue::from_str(self.tokens.top_gg.as_str()).unwrap(), 46 | body: to_vec(&json!({ 47 | "server_count": guild_count, 48 | "shard_count": shard_count, 49 | })) 50 | .unwrap(), 51 | } 52 | } 53 | 54 | fn discord_bots_gg_data( 55 | &self, 56 | bot_id: UserId, 57 | guild_count: usize, 58 | shard_count: NonZeroU16, 59 | ) -> BotListReq { 60 | BotListReq { 61 | url: format!("https://discord.bots.gg/api/v1/bots/{bot_id}/stats"), 62 | token: HeaderValue::from_str(self.tokens.discord_bots_gg.as_str()).unwrap(), 63 | body: to_vec(&json!({ 64 | "guildCount": guild_count, 65 | "shardCount": shard_count, 66 | })) 67 | .unwrap(), 68 | } 69 | } 70 | 71 | fn bots_on_discord_data(&self, bot_id: UserId, guild_count: usize) -> BotListReq { 72 | BotListReq { 73 | url: format!("https://bots.ondiscord.xyz/bot-api/bots/{bot_id}/guilds"), 74 | token: HeaderValue::from_str(self.tokens.bots_on_discord.as_str()).unwrap(), 75 | body: to_vec(&json!({"guildCount": guild_count})).unwrap(), 76 | } 77 | } 78 | } 79 | 80 | impl crate::Looper for BotListUpdater { 81 | const NAME: &'static str = "Bot List Updater"; 82 | const MILLIS: u64 = 1000 * 60 * 60; 83 | 84 | type Error = anyhow::Error; 85 | async fn loop_func(&self) -> Result<()> { 86 | let perform = |BotListReq { url, body, token }| async move { 87 | let headers = reqwest::header::HeaderMap::from_iter([ 88 | (AUTHORIZATION, token), 89 | (CONTENT_TYPE, HeaderValue::from_static("application/json")), 90 | ]); 91 | 92 | let request = self.reqwest.post(url).body(body).headers(headers); 93 | 94 | let resp_res = request.send().await; 95 | if let Err(err) = resp_res.and_then(reqwest::Response::error_for_status) { 96 | tracing::error!("{} Error: {:?}", Self::NAME, err); 97 | } 98 | }; 99 | 100 | let shard_count = self.cache.shard_count(); 101 | let bot_id = self.cache.current_user().id; 102 | let guild_count = self.cache.guild_count(); 103 | 104 | tokio::join!( 105 | perform(self.bots_on_discord_data(bot_id, guild_count)), 106 | perform(self.top_gg_data(bot_id, guild_count, shard_count)), 107 | perform(self.discord_bots_gg_data(bot_id, guild_count, shard_count)), 108 | ); 109 | 110 | Ok(()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tts_events/src/guild.rs: -------------------------------------------------------------------------------- 1 | use aformat::aformat; 2 | use reqwest::StatusCode; 3 | use tracing::info; 4 | 5 | use serenity::{all as serenity, builder::*}; 6 | 7 | use tts_core::structs::{Data, Result}; 8 | 9 | pub async fn handle_create( 10 | ctx: &serenity::Context, 11 | guild: &serenity::Guild, 12 | is_new: Option, 13 | ) -> Result<()> { 14 | if !is_new.unwrap() { 15 | return Ok(()); 16 | } 17 | 18 | let owner = guild.owner_id.to_user(&ctx).await?; 19 | let owner_tag = owner.tag(); 20 | 21 | let data = ctx.data_ref::(); 22 | let title = aformat!("Welcome to {}!", &ctx.cache.current_user().name); 23 | let embeds = [CreateEmbed::default() 24 | .title(title.as_str()) 25 | .description(format!(" 26 | Hello! Someone invited me to your server `{}`! 27 | TTS Bot is a text to speech bot, as in, it reads messages from a text channel and speaks it into a voice channel 28 | 29 | **Most commands need to be done on your server, such as `/setup` and `/join`** 30 | 31 | I need someone with the administrator permission to do `/setup #channel` 32 | You can then do `/join` in that channel and I will join your voice channel! 33 | Then, you can just type normal messages and I will say them, like magic! 34 | 35 | You can view all the commands with `/help` 36 | Ask questions by either responding here or asking on the support server!", 37 | guild.name)) 38 | .footer(CreateEmbedFooter::new(format!("Support Server: {} | Bot Invite: https://bit.ly/TTSBotSlash", data.config.main_server_invite))) 39 | .author(CreateEmbedAuthor::new(owner_tag.as_ref()).icon_url(owner.face()))]; 40 | 41 | match guild 42 | .owner_id 43 | .dm( 44 | &ctx.http, 45 | serenity::CreateMessage::default().embeds(&embeds), 46 | ) 47 | .await 48 | { 49 | Err(serenity::Error::Http(error)) 50 | if error.status_code() == Some(serenity::StatusCode::FORBIDDEN) => {} 51 | Err(error) => return Err(anyhow::Error::from(error)), 52 | _ => {} 53 | } 54 | 55 | match ctx 56 | .http 57 | .add_member_role( 58 | data.config.main_server, 59 | guild.owner_id, 60 | data.config.ofs_role, 61 | None, 62 | ) 63 | .await 64 | { 65 | Err(serenity::Error::Http(error)) 66 | if error.status_code() == Some(serenity::StatusCode::NOT_FOUND) => 67 | { 68 | return Ok(()); 69 | } 70 | Err(err) => return Err(anyhow::Error::from(err)), 71 | Result::Ok(()) => (), 72 | } 73 | 74 | info!("Added OFS role to {owner_tag}"); 75 | 76 | Ok(()) 77 | } 78 | 79 | pub async fn handle_delete( 80 | ctx: &serenity::Context, 81 | incomplete: serenity::UnavailableGuild, 82 | full: Option<&serenity::Guild>, 83 | ) -> Result<()> { 84 | if incomplete.unavailable { 85 | return Ok(()); 86 | } 87 | 88 | let data = ctx.data_ref::(); 89 | data.guilds_db.delete(incomplete.id.into()).await?; 90 | 91 | let Some(guild) = full else { return Ok(()) }; 92 | 93 | let owner_of_other_server = ctx 94 | .cache 95 | .guilds() 96 | .into_iter() 97 | .filter_map(|g| ctx.cache.guild(g)) 98 | .any(|g| g.owner_id == guild.owner_id); 99 | 100 | if owner_of_other_server { 101 | return Ok(()); 102 | } 103 | 104 | match ctx 105 | .http 106 | .remove_member_role( 107 | data.config.main_server, 108 | guild.owner_id, 109 | data.config.ofs_role, 110 | None, 111 | ) 112 | .await 113 | { 114 | Ok(()) => Ok(()), 115 | Err(serenity::Error::Http(serenity::HttpError::UnsuccessfulRequest(err))) 116 | if err.status_code == StatusCode::NOT_FOUND => 117 | { 118 | Ok(()) 119 | } 120 | Err(err) => Err(err.into()), 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/startup.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use small_fixed_array::{FixedString, TruncatingInto as _}; 4 | 5 | use poise::serenity_prelude as serenity; 6 | 7 | use tts_core::{ 8 | opt_ext::OptionTryUnwrap as _, 9 | structs::{GoogleGender, GoogleVoice, Result, TTSMode, WebhookConfig, WebhookConfigRaw}, 10 | }; 11 | 12 | pub async fn get_webhooks( 13 | http: &serenity::Http, 14 | webhooks_raw: WebhookConfigRaw, 15 | ) -> Result { 16 | let get_webhook = |url: reqwest::Url| async move { 17 | let (webhook_id, _) = serenity::parse_webhook(&url).try_unwrap()?; 18 | anyhow::Ok(webhook_id.to_webhook(http).await?) 19 | }; 20 | 21 | let (logs, errors, dm_logs) = tokio::try_join!( 22 | get_webhook(webhooks_raw.logs), 23 | get_webhook(webhooks_raw.errors), 24 | get_webhook(webhooks_raw.dm_logs), 25 | )?; 26 | 27 | println!("Fetched webhooks"); 28 | Ok(WebhookConfig { 29 | logs, 30 | errors, 31 | dm_logs, 32 | }) 33 | } 34 | 35 | async fn fetch_json(reqwest: &reqwest::Client, url: reqwest::Url, auth_header: &str) -> Result 36 | where 37 | T: serde::de::DeserializeOwned, 38 | { 39 | let resp = reqwest 40 | .get(url) 41 | .header("Authorization", auth_header) 42 | .send() 43 | .await? 44 | .error_for_status()? 45 | .json() 46 | .await?; 47 | 48 | Ok(resp) 49 | } 50 | 51 | pub async fn fetch_voices( 52 | reqwest: &reqwest::Client, 53 | mut tts_service: reqwest::Url, 54 | auth_key: Option<&str>, 55 | mode: TTSMode, 56 | ) -> Result { 57 | tts_service.set_path("voices"); 58 | tts_service 59 | .query_pairs_mut() 60 | .append_pair("mode", mode.into()) 61 | .append_pair("raw", "true") 62 | .finish(); 63 | 64 | let res = fetch_json(reqwest, tts_service, auth_key.unwrap_or("")).await?; 65 | 66 | println!("Loaded voices for TTS Mode: {mode}"); 67 | Ok(res) 68 | } 69 | 70 | pub async fn fetch_translation_languages( 71 | reqwest: &reqwest::Client, 72 | mut tts_service: reqwest::Url, 73 | auth_key: Option<&str>, 74 | ) -> Result, FixedString>> { 75 | tts_service.set_path("translation_languages"); 76 | 77 | let raw_langs: Vec<(String, FixedString)> = 78 | fetch_json(reqwest, tts_service, auth_key.unwrap_or("")).await?; 79 | 80 | let lang_map = raw_langs.into_iter().map(|(mut lang, name)| { 81 | lang.make_ascii_lowercase(); 82 | (lang.trunc_into(), name) 83 | }); 84 | 85 | println!("Loaded DeepL translation languages"); 86 | Ok(lang_map.collect()) 87 | } 88 | 89 | pub fn prepare_gcloud_voices( 90 | raw_map: Vec, 91 | ) -> BTreeMap, BTreeMap, GoogleGender>> { 92 | // {lang_accent: {variant: gender}} 93 | let mut cleaned_map = BTreeMap::new(); 94 | for gvoice in raw_map { 95 | let variant = gvoice 96 | .name 97 | .splitn(3, '-') 98 | .nth(2) 99 | .and_then(|mode_variant| mode_variant.split_once('-')) 100 | .filter(|(mode, _)| *mode == "Standard") 101 | .map(|(_, variant)| variant); 102 | 103 | if let Some(variant) = variant { 104 | let [language] = gvoice.language_codes; 105 | cleaned_map 106 | .entry(language) 107 | .or_insert_with(BTreeMap::new) 108 | .insert(FixedString::from_str_trunc(variant), gvoice.ssml_gender); 109 | } 110 | } 111 | 112 | cleaned_map 113 | } 114 | 115 | pub async fn send_startup_message( 116 | http: &serenity::Http, 117 | log_webhook: &serenity::Webhook, 118 | ) -> Result { 119 | let startup_builder = serenity::ExecuteWebhook::default().content("**TTS Bot is starting up**"); 120 | let startup_message = log_webhook.execute(http, true, startup_builder).await?; 121 | 122 | Ok(startup_message.unwrap().id) 123 | } 124 | -------------------------------------------------------------------------------- /tts_core/src/constants.rs: -------------------------------------------------------------------------------- 1 | pub const RED: u32 = 0xff0000; 2 | pub const FREE_NEUTRAL_COLOUR: u32 = 0x3498db; 3 | pub const PREMIUM_NEUTRAL_COLOUR: u32 = 0xcaa652; 4 | 5 | pub const OPTION_SEPERATORS: [&str; 4] = [ 6 | ":small_orange_diamond:", 7 | ":small_blue_diamond:", 8 | ":small_red_triangle:", 9 | ":star:", 10 | ]; 11 | 12 | pub const GTTS_DISABLED_ERROR: &str = 13 | "The `gTTS` voice mode is currently disabled due to maintenance so cannot be used."; 14 | 15 | pub const DM_WELCOME_MESSAGE: &str = " 16 | **All messages after this will be sent to a private channel where we can assist you.** 17 | **DO NOT SEND PERSONAL INFORMATION TO ANY DISCORD BOT, BOT DEVELOPERS CAN SEE THE MESSAGES.** 18 | Please keep in mind that we aren't always online and get a lot of messages, so if you don't get a response within a day repeat your message. 19 | There are some basic rules if you want to get help though: 20 | `1.` Ask your question, don't just ask for help 21 | `2.` Don't spam, troll, or send random stuff (including server invites) 22 | `3.` Many questions are answered in `-help`, try that first (also the default prefix is `-`) 23 | "; 24 | 25 | pub const DB_SETUP_QUERY: &str = " 26 | CREATE type TTSMode AS ENUM ( 27 | 'gtts', 28 | 'polly', 29 | 'espeak', 30 | 'gcloud' 31 | ); 32 | 33 | CREATE TABLE userinfo ( 34 | user_id bigint PRIMARY KEY, 35 | dm_blocked bool DEFAULT False, 36 | dm_welcomed bool DEFAULT false, 37 | voice_mode TTSMode, 38 | premium_voice_mode TTSMode 39 | ); 40 | 41 | CREATE TABLE guilds ( 42 | guild_id bigint PRIMARY KEY, 43 | channel bigint DEFAULT 0, 44 | premium_user bigint, 45 | required_role bigint, 46 | xsaid bool DEFAULT True, 47 | bot_ignore bool DEFAULT True, 48 | auto_join bool DEFAULT False, 49 | to_translate bool DEFAULT False, 50 | require_voice bool DEFAULT True, 51 | msg_length smallint DEFAULT 30, 52 | repeated_chars smallint DEFAULT 0, 53 | prefix varchar(6) DEFAULT '-', 54 | required_prefix varchar(6), 55 | target_lang varchar(5), 56 | audience_ignore bool DEFAULT True, 57 | voice_mode TTSMode DEFAULT 'gtts', 58 | 59 | FOREIGN KEY (premium_user) 60 | REFERENCES userinfo (user_id) 61 | ON DELETE CASCADE 62 | ); 63 | 64 | CREATE TABLE guild_voice ( 65 | guild_id bigint, 66 | mode TTSMode, 67 | voice text NOT NULL, 68 | 69 | PRIMARY KEY (guild_id, mode), 70 | 71 | FOREIGN KEY (guild_id) 72 | REFERENCES guilds (guild_id) 73 | ON DELETE CASCADE 74 | ); 75 | 76 | CREATE TABLE user_voice ( 77 | user_id bigint, 78 | mode TTSMode, 79 | voice text, 80 | speaking_rate real, 81 | 82 | PRIMARY KEY (user_id, mode), 83 | 84 | FOREIGN KEY (user_id) 85 | REFERENCES userinfo (user_id) 86 | ON DELETE CASCADE 87 | ); 88 | 89 | CREATE TABLE nicknames ( 90 | guild_id bigint, 91 | user_id bigint, 92 | name text, 93 | 94 | PRIMARY KEY (guild_id, user_id), 95 | 96 | FOREIGN KEY (guild_id) 97 | REFERENCES guilds (guild_id) 98 | ON DELETE CASCADE, 99 | 100 | FOREIGN KEY (user_id) 101 | REFERENCES userinfo (user_id) 102 | ON DELETE CASCADE 103 | ); 104 | 105 | CREATE TABLE analytics ( 106 | event text NOT NULL, 107 | count int NOT NULL, 108 | is_command bool NOT NULL, 109 | date_collected date NOT NULL DEFAULT CURRENT_DATE, 110 | PRIMARY KEY (event, is_command, date_collected) 111 | ); 112 | 113 | CREATE TABLE errors ( 114 | traceback text PRIMARY KEY, 115 | message_id bigint NOT NULL, 116 | occurrences int DEFAULT 1 117 | ); 118 | 119 | INSERT INTO guilds(guild_id) VALUES(0); 120 | INSERT INTO userinfo(user_id) VALUES(0); 121 | INSERT INTO nicknames(guild_id, user_id) VALUES (0, 0); 122 | 123 | INSERT INTO user_voice(user_id, mode) VALUES(0, 'gtts'); 124 | INSERT INTO guild_voice(guild_id, mode, voice) VALUES(0, 'gtts', 'en'); 125 | "; 126 | -------------------------------------------------------------------------------- /tts_events/src/member.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude as serenity; 2 | use reqwest::StatusCode; 3 | 4 | use tts_core::{ 5 | common::{confirm_dialog_buttons, confirm_dialog_wait, remove_premium}, 6 | constants::PREMIUM_NEUTRAL_COLOUR, 7 | structs::{Data, Result}, 8 | }; 9 | 10 | fn is_guild_owner(cache: &serenity::Cache, user_id: serenity::UserId) -> bool { 11 | cache 12 | .guilds() 13 | .into_iter() 14 | .find_map(|id| cache.guild(id).map(|g| g.owner_id == user_id)) 15 | .unwrap_or(false) 16 | } 17 | 18 | async fn add_ofs_role(data: &Data, http: &serenity::Http, user_id: serenity::UserId) -> Result<()> { 19 | match http 20 | .add_member_role(data.config.main_server, user_id, data.config.ofs_role, None) 21 | .await 22 | { 23 | // Unknown member 24 | Err(serenity::Error::Http(serenity::HttpError::UnsuccessfulRequest(err))) 25 | if err.error.code == serenity::JsonErrorCode::UnknownMember => 26 | { 27 | Ok(()) 28 | } 29 | 30 | r => r.map_err(Into::into), 31 | } 32 | } 33 | 34 | pub async fn handle_addition(ctx: &serenity::Context, member: &serenity::Member) -> Result<()> { 35 | let data = ctx.data_ref::(); 36 | if member.guild_id == data.config.main_server && is_guild_owner(&ctx.cache, member.user.id) { 37 | add_ofs_role(data, &ctx.http, member.user.id).await?; 38 | } 39 | 40 | Ok(()) 41 | } 42 | 43 | async fn dm_premium_notice( 44 | http: &serenity::Http, 45 | user_id: serenity::UserId, 46 | ) -> serenity::Result { 47 | let embed = [serenity::CreateEmbed::new() 48 | .colour(PREMIUM_NEUTRAL_COLOUR) 49 | .title("TTS Bot Premium - Important Message") 50 | .description( 51 | "You have just left a server that you have assigned as premium! 52 | Do you want to remove that server from your assigned slots?", 53 | )]; 54 | 55 | let buttons = confirm_dialog_buttons( 56 | "Keep premium subscription assigned", 57 | "Unassign premium subscription", 58 | ); 59 | 60 | let components = 61 | serenity::CreateComponent::ActionRow(serenity::CreateActionRow::buttons(&buttons)); 62 | let notice = serenity::CreateMessage::new() 63 | .embeds(&embed) 64 | .components(std::slice::from_ref(&components)); 65 | 66 | user_id.dm(http, notice).await 67 | } 68 | 69 | pub async fn handle_removal( 70 | ctx: &serenity::Context, 71 | guild_id: serenity::GuildId, 72 | user_id: serenity::UserId, 73 | ) -> Result<()> { 74 | let data = ctx.data_ref::(); 75 | 76 | let guild_row = data.guilds_db.get(guild_id.into()).await?; 77 | let Some(premium_user) = guild_row.premium_user else { 78 | return Ok(()); 79 | }; 80 | 81 | if premium_user != user_id { 82 | return Ok(()); 83 | } 84 | 85 | if !data.is_premium_simple(&ctx.http, guild_id).await? { 86 | return Ok(()); 87 | } 88 | 89 | let msg = match dm_premium_notice(&ctx.http, user_id).await { 90 | Ok(msg) => msg, 91 | Err(err) => { 92 | // We cannot DM this premium user, just remove premium by default. 93 | remove_premium(data, guild_id).await?; 94 | if let serenity::Error::Http(serenity::HttpError::UnsuccessfulRequest(err)) = &err 95 | && err.status_code == StatusCode::FORBIDDEN 96 | { 97 | return Ok(()); 98 | } 99 | 100 | return Err(err.into()); 101 | } 102 | }; 103 | 104 | let guild_name = match ctx.cache.guild(guild_id) { 105 | Some(g) => &g.name.clone(), 106 | None => "", 107 | }; 108 | 109 | let response = match confirm_dialog_wait(ctx, msg.id, premium_user).await? { 110 | Some(true) => format!("Okay, kept your premium assigned to {guild_name} ({guild_id})."), 111 | Some(false) => { 112 | remove_premium(data, guild_id).await?; 113 | format!("Okay, removed your premium assignment from {guild_name} ({guild_id}).") 114 | } 115 | None => { 116 | remove_premium(data, guild_id).await?; 117 | format!( 118 | "You did not respond to whether or not to remove premium assignment from {guild_name} ({guild_id}), so it has been unassigned." 119 | ) 120 | } 121 | }; 122 | 123 | user_id 124 | .dm(&ctx.http, serenity::CreateMessage::new().content(response)) 125 | .await?; 126 | 127 | Ok(()) 128 | } 129 | -------------------------------------------------------------------------------- /tts_events/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_name_repetitions)] 2 | 3 | mod channel; 4 | mod guild; 5 | mod member; 6 | mod message; 7 | mod other; 8 | mod ready; 9 | mod voice_state; 10 | 11 | use poise::serenity_prelude as serenity; 12 | 13 | use tts_core::errors; 14 | 15 | #[must_use] 16 | pub fn get_intents() -> serenity::GatewayIntents { 17 | serenity::GatewayIntents::GUILDS 18 | | serenity::GatewayIntents::GUILD_MESSAGES 19 | | serenity::GatewayIntents::DIRECT_MESSAGES 20 | | serenity::GatewayIntents::GUILD_VOICE_STATES 21 | | serenity::GatewayIntents::GUILD_MEMBERS 22 | | serenity::GatewayIntents::MESSAGE_CONTENT 23 | } 24 | 25 | #[derive(Clone, Copy)] 26 | pub struct EventHandler; 27 | 28 | #[serenity::async_trait] 29 | impl serenity::EventHandler for EventHandler { 30 | async fn dispatch(&self, ctx: &serenity::Context, event: &serenity::FullEvent) { 31 | match event { 32 | serenity::FullEvent::Message { new_message, .. } => { 33 | if let Err(err) = message::handle(ctx, new_message).await 34 | && let Err(err) = errors::handle_message(ctx, new_message, err).await 35 | { 36 | tracing::error!("Error in message event handler: {err:?}"); 37 | } 38 | } 39 | serenity::FullEvent::Ready { data_about_bot, .. } => { 40 | if let Err(err) = ready::handle(ctx, data_about_bot).await 41 | && let Err(err) = errors::handle_unexpected_default(ctx, "Ready", err).await 42 | { 43 | tracing::error!("Error in message event handler: {err:?}"); 44 | } 45 | } 46 | serenity::FullEvent::GuildCreate { guild, is_new, .. } => { 47 | if let Err(err) = guild::handle_create(ctx, guild, *is_new).await 48 | && let Err(err) = 49 | errors::handle_guild("GuildCreate", ctx, Some(guild), err).await 50 | { 51 | tracing::error!("Error in guild create handler: {err:?}"); 52 | } 53 | } 54 | serenity::FullEvent::GuildDelete { 55 | incomplete, full, .. 56 | } => { 57 | if let Err(err) = guild::handle_delete(ctx, *incomplete, full.as_ref()).await 58 | && let Err(err) = 59 | errors::handle_guild("GuildDelete", ctx, full.as_ref(), err).await 60 | { 61 | tracing::error!("Error in guild delete handler: {err:?}"); 62 | } 63 | } 64 | serenity::FullEvent::GuildMemberAddition { new_member, .. } => { 65 | if let Err(err) = member::handle_addition(ctx, new_member).await 66 | && let Err(err) = errors::handle_member(ctx, new_member, err).await 67 | { 68 | tracing::error!("Error in guild member addition handler: {err:?}"); 69 | } 70 | } 71 | serenity::FullEvent::GuildMemberRemoval { guild_id, user, .. } => { 72 | if let Err(err) = member::handle_removal(ctx, *guild_id, user.id).await { 73 | tracing::error!("Error in guild member removal handler: {err:?}"); 74 | } 75 | } 76 | serenity::FullEvent::VoiceStateUpdate { old, new, .. } => { 77 | if let Err(err) = voice_state::handle(ctx, old.as_ref(), new).await 78 | && let Err(err) = 79 | errors::handle_unexpected_default(ctx, "VoiceStateUpdate", err).await 80 | { 81 | tracing::error!("Error in voice state update handler: {err:?}"); 82 | } 83 | } 84 | serenity::FullEvent::ChannelDelete { channel, .. } => { 85 | if let Err(err) = channel::handle_delete(ctx, channel).await { 86 | tracing::error!("Error in channel delete handler: {err:?}"); 87 | } 88 | } 89 | serenity::FullEvent::InteractionCreate { interaction, .. } => { 90 | if let Err(err) = other::interaction_create(ctx, interaction).await 91 | && let Err(err) = 92 | errors::handle_unexpected_default(ctx, "InteractionCreate", err).await 93 | { 94 | tracing::error!("Error in interaction create handler: {err:?}"); 95 | } 96 | } 97 | serenity::FullEvent::Resume { .. } => { 98 | other::resume(ctx); 99 | } 100 | 101 | _ => {} 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tts_events/src/ready.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Write, num::NonZeroU16, sync::atomic::Ordering}; 2 | 3 | use aformat::aformat; 4 | 5 | use self::serenity::{builder::*, small_fixed_array::FixedString}; 6 | use poise::serenity_prelude as serenity; 7 | 8 | use tts_core::{ 9 | constants::FREE_NEUTRAL_COLOUR, 10 | structs::{Data, Result}, 11 | }; 12 | use tts_tasks::Looper; 13 | 14 | #[cfg(unix)] 15 | fn clear_allocator_cache() { 16 | unsafe { 17 | libc::malloc_trim(0); 18 | } 19 | } 20 | 21 | #[cfg(not(unix))] 22 | fn clear_allocator_cache() {} 23 | 24 | fn generate_status( 25 | shards: &dashmap::DashMap, 26 | ) -> String { 27 | let mut shards: Vec<_> = shards.iter().collect(); 28 | shards.sort_by_key(|entry| *entry.key()); 29 | 30 | let mut run_start = 0; 31 | let mut last_stage = None; 32 | let mut status = String::with_capacity(shards.len()); 33 | 34 | for (i, entry) in shards.iter().enumerate() { 35 | let (id, (info, _)) = entry.pair(); 36 | if Some(info.stage) == last_stage && i != (shards.len() - 1) { 37 | continue; 38 | } 39 | 40 | if let Some(last_stage) = last_stage { 41 | writeln!(status, "Shards {run_start}-{id}: {last_stage}").unwrap(); 42 | } 43 | 44 | last_stage = Some(info.stage); 45 | run_start = id.0; 46 | } 47 | 48 | status 49 | } 50 | 51 | async fn update_startup_message( 52 | ctx: &serenity::Context, 53 | data: &Data, 54 | user_name: &FixedString, 55 | status: String, 56 | shard_count: Option, 57 | ) -> Result<()> { 58 | let title: &str = if let Some(shard_count) = shard_count { 59 | &aformat!("{user_name} is starting up {shard_count} shards!") 60 | } else { 61 | &aformat!( 62 | "{user_name} started in {} seconds", 63 | data.start_time.elapsed().unwrap().as_secs() 64 | ) 65 | }; 66 | 67 | let builder = serenity::EditWebhookMessage::default().content("").embed( 68 | CreateEmbed::default() 69 | .description(status) 70 | .colour(FREE_NEUTRAL_COLOUR) 71 | .title(title), 72 | ); 73 | 74 | data.webhooks 75 | .logs 76 | .edit_message(&ctx.http, data.startup_message, builder) 77 | .await?; 78 | 79 | Ok(()) 80 | } 81 | 82 | #[cold] 83 | fn finalize_startup(ctx: &serenity::Context, data: &Data) { 84 | if let Some(bot_list_tokens) = data.bot_list_tokens.lock().take() { 85 | let stats_updater = tts_tasks::bot_list_updater::BotListUpdater::new( 86 | data.reqwest.clone(), 87 | ctx.cache.clone(), 88 | bot_list_tokens, 89 | ); 90 | 91 | tokio::spawn(stats_updater.start()); 92 | } 93 | 94 | if let Some(website_info) = data.website_info.lock().take() { 95 | let premium_config = data.premium_config.as_ref(); 96 | let patreon_service = premium_config.map(|c| c.patreon_service.clone()); 97 | 98 | let web_updater = tts_tasks::web_updater::Updater { 99 | reqwest: data.reqwest.clone(), 100 | cache: ctx.cache.clone(), 101 | pool: data.pool.clone(), 102 | config: website_info, 103 | patreon_service, 104 | }; 105 | 106 | tokio::spawn(web_updater.start()); 107 | } 108 | 109 | // Tell glibc to let go of the memory it's holding onto. 110 | // We are very unlikely to reach the peak of memory allocation that was just hit. 111 | clear_allocator_cache(); 112 | } 113 | 114 | pub async fn handle(ctx: &serenity::Context, data_about_bot: &serenity::Ready) -> Result<()> { 115 | let data = ctx.data_ref::(); 116 | 117 | let shard_count = ctx.cache.shard_count(); 118 | let is_last_shard = (ctx.shard_id.0 + 1) == shard_count.get(); 119 | 120 | // Don't update the welcome message for concurrent shard startups. 121 | if let Ok(_guard) = data.update_startup_lock.try_lock() { 122 | let status = generate_status(&ctx.runners); 123 | let shard_count = (!is_last_shard).then_some(shard_count); 124 | 125 | update_startup_message(ctx, data, &data_about_bot.user.name, status, shard_count).await?; 126 | } 127 | 128 | data.regex_cache 129 | .bot_mention 130 | .get_or_init(|| regex::Regex::new(&aformat!("^<@!?{}>$", data_about_bot.user.id)).unwrap()); 131 | 132 | if is_last_shard && !data.fully_started.swap(true, Ordering::SeqCst) { 133 | finalize_startup(ctx, data); 134 | } else if data.fully_started.load(Ordering::SeqCst) { 135 | tracing::info!("Shard {} is now ready", ctx.shard_id); 136 | } 137 | 138 | Ok(()) 139 | } 140 | -------------------------------------------------------------------------------- /tts_tasks/src/web_updater.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | sync::Arc, 4 | }; 5 | 6 | use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; 7 | 8 | use serenity::all as serenity; 9 | 10 | use tts_core::structs::{Result, TTSMode, WebsiteInfo}; 11 | 12 | #[allow(dead_code, clippy::match_same_arms)] 13 | fn remember_to_update_analytics_query() { 14 | match TTSMode::gTTS { 15 | TTSMode::gTTS => (), 16 | TTSMode::Polly => (), 17 | TTSMode::eSpeak => (), 18 | TTSMode::gCloud => (), 19 | } 20 | } 21 | 22 | fn count_members<'a>(guilds: impl Iterator>) -> u64 { 23 | guilds.map(|g| g.member_count).sum() 24 | } 25 | 26 | #[derive(serde::Serialize)] 27 | struct Statistics { 28 | premium_guild: u32, 29 | premium_user: u64, 30 | message: u64, 31 | guild: u32, 32 | user: u64, 33 | } 34 | 35 | pub struct Updater { 36 | pub patreon_service: Option, 37 | pub cache: Arc, 38 | pub reqwest: reqwest::Client, 39 | pub config: WebsiteInfo, 40 | pub pool: sqlx::PgPool, 41 | } 42 | 43 | impl crate::Looper for Updater { 44 | const NAME: &'static str = "WebUpdater"; 45 | const MILLIS: u64 = 1000 * 60 * 60; 46 | 47 | type Error = anyhow::Error; 48 | async fn loop_func(&self) -> Result<()> { 49 | #[derive(sqlx::FromRow)] 50 | struct AnalyticsQueryResult { 51 | count: i32, 52 | } 53 | 54 | #[derive(sqlx::FromRow)] 55 | struct PremiumGuildsQueryResult { 56 | guild_id: i64, 57 | } 58 | 59 | let patreon_members = if let Some(mut patreon_service) = self.patreon_service.clone() { 60 | patreon_service.set_path("members"); 61 | let raw_members: HashMap = self 62 | .reqwest 63 | .get(patreon_service) 64 | .send() 65 | .await? 66 | .error_for_status()? 67 | .json() 68 | .await?; 69 | 70 | raw_members.into_keys().collect() 71 | } else { 72 | Vec::new() 73 | }; 74 | 75 | let (message_count, premium_guild_ids) = { 76 | let mut db_conn = self.pool.acquire().await?; 77 | let message_count = sqlx::query_as::<_, AnalyticsQueryResult>( 78 | " 79 | SELECT count FROM analytics 80 | WHERE date_collected = (CURRENT_DATE - 1) AND ( 81 | event = 'gTTS_tts' OR 82 | event = 'eSpeak_tts' OR 83 | event = 'gCloud_tts' OR 84 | event = 'Polly_tts' 85 | ) 86 | ", 87 | ) 88 | .fetch_all(&mut *db_conn) 89 | .await? 90 | .into_iter() 91 | .map(|r| r.count as i64) 92 | .sum::(); 93 | 94 | let premium_guild_ids = sqlx::query_as::<_, PremiumGuildsQueryResult>( 95 | "SELECT guild_id FROM guilds WHERE premium_user = ANY($1)", 96 | ) 97 | .bind(&patreon_members) 98 | .fetch_all(&mut *db_conn) 99 | .await? 100 | .into_iter() 101 | .map(|g| g.guild_id) 102 | .collect::>(); 103 | 104 | (message_count, premium_guild_ids) 105 | }; 106 | 107 | let guild_ids = self.cache.guilds(); 108 | 109 | let guild_ref_iter = guild_ids.iter().filter_map(|g| self.cache.guild(*g)); 110 | let user = count_members(guild_ref_iter.clone()); 111 | 112 | let premium_guild_ref_iter = 113 | guild_ref_iter.filter(|g| premium_guild_ids.contains(&(g.id.get() as i64))); 114 | let premium_user = count_members(premium_guild_ref_iter.clone()); 115 | let premium_guild_count = premium_guild_ref_iter.count(); 116 | 117 | let stats = Statistics { 118 | user, 119 | premium_user, 120 | message: message_count as u64, 121 | guild: guild_ids.len() as u32, 122 | premium_guild: premium_guild_count as u32, 123 | }; 124 | 125 | let url = { 126 | let mut url = self.config.url.clone(); 127 | url.set_path("/update_stats"); 128 | url 129 | }; 130 | 131 | self.reqwest 132 | .post(url) 133 | .header(AUTHORIZATION, self.config.stats_key.clone()) 134 | .header(CONTENT_TYPE, "application/json") 135 | .body(serde_json::to_string(&stats)?) 136 | .send() 137 | .await? 138 | .error_for_status()?; 139 | 140 | Ok(()) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tts_commands/src/settings/voice_paginator.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use poise::serenity_prelude as serenity; 4 | use serenity::{CollectComponentInteractions, builder::*, small_fixed_array::FixedString}; 5 | 6 | use tts_core::structs::{Context, TTSMode}; 7 | 8 | pub struct MenuPaginator<'a> { 9 | index: usize, 10 | mode: TTSMode, 11 | ctx: Context<'a>, 12 | pages: Vec, 13 | footer: Cow<'a, str>, 14 | current_voice: String, 15 | } 16 | 17 | impl<'a> MenuPaginator<'a> { 18 | pub fn new( 19 | ctx: Context<'a>, 20 | pages: Vec, 21 | current_voice: String, 22 | mode: TTSMode, 23 | footer: Cow<'a, str>, 24 | ) -> Self { 25 | Self { 26 | ctx, 27 | pages, 28 | current_voice, 29 | mode, 30 | footer, 31 | index: 0, 32 | } 33 | } 34 | 35 | fn create_page(&self, page: &str) -> CreateEmbed<'_> { 36 | let author = self.ctx.author(); 37 | let bot_user = &self.ctx.cache().current_user().name; 38 | 39 | CreateEmbed::default() 40 | .title(format!("{bot_user} Voices | Mode: `{}`", self.mode)) 41 | .description(format!("**Currently Supported Voice**\n{page}")) 42 | .field("Current voice used", &self.current_voice, false) 43 | .author(CreateEmbedAuthor::new(&*author.name).icon_url(author.face())) 44 | .footer(CreateEmbedFooter::new(self.footer.as_ref())) 45 | } 46 | 47 | fn create_action_row(&self, disabled: bool) -> serenity::CreateComponent<'_> { 48 | let buttons = ["⏮️", "◀", "⏹️", "▶️", "⏭️"] 49 | .into_iter() 50 | .map(|emoji| { 51 | CreateButton::new(emoji) 52 | .style(serenity::ButtonStyle::Primary) 53 | .emoji(serenity::ReactionType::Unicode( 54 | FixedString::from_static_trunc(emoji), 55 | )) 56 | .disabled( 57 | disabled 58 | || (["⏮️", "◀"].contains(&emoji) && self.index == 0) 59 | || (["▶️", "⏭️"].contains(&emoji) 60 | && self.index == (self.pages.len() - 1)), 61 | ) 62 | }) 63 | .collect(); 64 | 65 | CreateComponent::ActionRow(CreateActionRow::Buttons(buttons)) 66 | } 67 | 68 | async fn create_message(&self) -> serenity::Result { 69 | let components = [self.create_action_row(false)]; 70 | let builder = poise::CreateReply::default() 71 | .embed(self.create_page(&self.pages[self.index])) 72 | .components(&components); 73 | 74 | self.ctx.send(builder).await?.message().await.map(|m| m.id) 75 | } 76 | 77 | async fn edit_message( 78 | &self, 79 | message: serenity::MessageId, 80 | disable: bool, 81 | ) -> serenity::Result { 82 | let http = self.ctx.http(); 83 | let channel_id = self.ctx.channel_id(); 84 | 85 | let components = [self.create_action_row(disable)]; 86 | let builder = EditMessage::default() 87 | .embed(self.create_page(&self.pages[self.index])) 88 | .components(&components); 89 | 90 | Ok(channel_id.edit_message(http, message, builder).await?.id) 91 | } 92 | 93 | pub async fn start(mut self) -> serenity::Result<()> { 94 | let mut message_id = self.create_message().await?; 95 | let serenity_context = self.ctx.serenity_context(); 96 | 97 | loop { 98 | let builder = message_id 99 | .collect_component_interactions(serenity_context) 100 | .timeout(std::time::Duration::from_secs(60 * 5)) 101 | .author_id(self.ctx.author().id); 102 | 103 | let Some(interaction) = builder.await else { 104 | break Ok(()); 105 | }; 106 | 107 | message_id = match interaction.data.custom_id.as_str() { 108 | "⏮️" => { 109 | self.index = 0; 110 | self.edit_message(message_id, false).await? 111 | } 112 | "◀" => { 113 | self.index -= 1; 114 | self.edit_message(message_id, false).await? 115 | } 116 | "⏹️" => { 117 | self.edit_message(message_id, true).await?; 118 | return interaction.defer(&serenity_context.http).await; 119 | } 120 | "▶️" => { 121 | self.index += 1; 122 | self.edit_message(message_id, false).await? 123 | } 124 | "⏭️" => { 125 | self.index = self.pages.len() - 1; 126 | self.edit_message(message_id, false).await? 127 | } 128 | _ => unreachable!(), 129 | }; 130 | interaction.defer(&serenity_context.http).await?; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tts_tasks/src/logging.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Write, sync::Arc}; 2 | 3 | use aformat::{CapStr, aformat}; 4 | use anyhow::Result; 5 | use itertools::Itertools as _; 6 | use parking_lot::Mutex; 7 | use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _}; 8 | 9 | use serenity::all::{ExecuteWebhook, Http, Webhook}; 10 | 11 | use crate::Looper; 12 | 13 | type LogMessage = (&'static str, String); 14 | 15 | fn get_avatar(level: tracing::Level) -> &'static str { 16 | match level { 17 | tracing::Level::TRACE | tracing::Level::DEBUG => { 18 | "https://cdn.discordapp.com/embed/avatars/1.png" 19 | } 20 | tracing::Level::INFO => "https://cdn.discordapp.com/embed/avatars/0.png", 21 | tracing::Level::WARN => "https://cdn.discordapp.com/embed/avatars/3.png", 22 | tracing::Level::ERROR => "https://cdn.discordapp.com/embed/avatars/4.png", 23 | } 24 | } 25 | 26 | pub struct WebhookLogger { 27 | http: Arc, 28 | 29 | pending_logs: Mutex>>, 30 | 31 | normal_logs: Webhook, 32 | error_logs: Webhook, 33 | } 34 | 35 | impl WebhookLogger { 36 | pub fn init(http: Arc, normal_logs: Webhook, error_logs: Webhook) { 37 | let logger = ArcWrapper(Arc::new(Self { 38 | http, 39 | normal_logs, 40 | error_logs, 41 | 42 | pending_logs: Mutex::default(), 43 | })); 44 | 45 | tracing_subscriber::registry().with(logger.clone()).init(); 46 | tokio::spawn(logger.0.start()); 47 | } 48 | } 49 | 50 | impl Looper for Arc { 51 | const NAME: &'static str = "Logging"; 52 | const MILLIS: u64 = 1100; 53 | 54 | type Error = !; 55 | async fn loop_func(&self) -> Result<(), Self::Error> { 56 | let pending_logs = self.pending_logs.lock().drain().collect::>(); 57 | 58 | for (severity, messages) in pending_logs { 59 | let mut chunks: Vec = Vec::with_capacity(messages.len()); 60 | let mut pre_chunked = String::new(); 61 | 62 | for (target, log_message) in messages { 63 | for line in log_message.lines() { 64 | writeln!(pre_chunked, "`[{target}]`: {line}") 65 | .expect("String::write_fmt should never not fail"); 66 | } 67 | } 68 | 69 | for line in pre_chunked.split_inclusive('\n') { 70 | for chunk in line 71 | .chars() 72 | .chunks(2000) 73 | .into_iter() 74 | .map(Iterator::collect::) 75 | { 76 | if let Some(current_chunk) = chunks.last_mut() { 77 | if current_chunk.len() + chunk.len() > 2000 { 78 | chunks.push(chunk); 79 | } else { 80 | current_chunk.push_str(&chunk); 81 | } 82 | } else { 83 | chunks.push(chunk); 84 | } 85 | } 86 | } 87 | 88 | let webhook = if tracing::Level::ERROR >= severity { 89 | &self.error_logs 90 | } else { 91 | &self.normal_logs 92 | }; 93 | 94 | let webhook_name = aformat!("TTS-Webhook [{}]", CapStr::<5>(severity.as_str())); 95 | 96 | for chunk in chunks { 97 | let builder = ExecuteWebhook::default() 98 | .content(&chunk) 99 | .username(webhook_name.as_str()) 100 | .avatar_url(get_avatar(severity)); 101 | 102 | if let Err(err) = webhook.execute(&self.http, false, builder).await { 103 | eprintln!("Failed to send log message: {err:?}\n{chunk}"); 104 | } 105 | } 106 | } 107 | 108 | Ok(()) 109 | } 110 | } 111 | 112 | pub struct StringVisitor<'a> { 113 | string: &'a mut String, 114 | } 115 | 116 | impl tracing::field::Visit for StringVisitor<'_> { 117 | fn record_debug(&mut self, _field: &tracing::field::Field, value: &dyn std::fmt::Debug) { 118 | write!(self.string, "{value:?}").unwrap(); 119 | } 120 | 121 | fn record_str(&mut self, _field: &tracing::field::Field, value: &str) { 122 | self.string.push_str(value); 123 | } 124 | } 125 | 126 | impl tracing_subscriber::Layer for ArcWrapper { 127 | fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) { 128 | let metadata = event.metadata(); 129 | let enabled = if metadata.target().starts_with("tts_") { 130 | tracing::Level::INFO >= *metadata.level() 131 | } else { 132 | tracing::Level::WARN >= *metadata.level() 133 | }; 134 | 135 | if !enabled { 136 | return; 137 | } 138 | 139 | let mut message = String::new(); 140 | event.record(&mut StringVisitor { 141 | string: &mut message, 142 | }); 143 | 144 | self.pending_logs 145 | .lock() 146 | .entry(*metadata.level()) 147 | .or_default() 148 | .push((metadata.target(), message)); 149 | } 150 | } 151 | 152 | // So we can impl tracing::Subscriber for Arc 153 | pub struct ArcWrapper(pub Arc); 154 | impl Clone for ArcWrapper { 155 | fn clone(&self) -> Self { 156 | Self(Arc::clone(&self.0)) 157 | } 158 | } 159 | 160 | impl std::ops::Deref for ArcWrapper { 161 | type Target = T; 162 | fn deref(&self) -> &Self::Target { 163 | &self.0 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /tts_core/src/traits.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, sync::Arc}; 2 | 3 | use poise::serenity_prelude as serenity; 4 | 5 | use crate::{ 6 | constants, 7 | constants::{FREE_NEUTRAL_COLOUR, PREMIUM_NEUTRAL_COLOUR}, 8 | opt_ext::OptionTryUnwrap, 9 | require_guild, 10 | structs::{Context, JoinVCToken, Result, TTSMode}, 11 | }; 12 | 13 | pub trait PoiseContextExt<'ctx> { 14 | async fn send_error( 15 | &'ctx self, 16 | error_message: impl Into>, 17 | ) -> Result>>; 18 | async fn send_ephemeral( 19 | &'ctx self, 20 | message: impl Into>, 21 | ) -> Result>; 22 | 23 | async fn neutral_colour(&self) -> u32; 24 | fn author_vc(&self) -> Option; 25 | fn author_permissions(&self) -> Result; 26 | } 27 | 28 | impl<'ctx> PoiseContextExt<'ctx> for Context<'ctx> { 29 | fn author_vc(&self) -> Option { 30 | require_guild!(self, None) 31 | .voice_states 32 | .get(&self.author().id) 33 | .and_then(|vc| vc.channel_id) 34 | } 35 | 36 | async fn neutral_colour(&self) -> u32 { 37 | if let Some(guild_id) = self.guild_id() { 38 | let row = self.data().guilds_db.get(guild_id.get() as i64).await; 39 | if row.map(|row| row.voice_mode).is_ok_and(TTSMode::is_premium) { 40 | return PREMIUM_NEUTRAL_COLOUR; 41 | } 42 | } 43 | 44 | FREE_NEUTRAL_COLOUR 45 | } 46 | 47 | fn author_permissions(&self) -> Result { 48 | match self { 49 | poise::Context::Application(poise::ApplicationContext { interaction, .. }) => { 50 | let channel = interaction.channel.as_ref().try_unwrap()?; 51 | let Some(author_member) = interaction.member.as_deref() else { 52 | return Ok(serenity::Permissions::dm_permissions()); 53 | }; 54 | 55 | let mut permissions = author_member.permissions.try_unwrap()?; 56 | if matches!(channel, serenity::GenericInteractionChannel::Thread(_)) { 57 | permissions.set( 58 | serenity::Permissions::SEND_MESSAGES, 59 | permissions.send_messages_in_threads(), 60 | ); 61 | } 62 | 63 | Ok(permissions) 64 | } 65 | poise::Context::Prefix(poise::PrefixContext { msg, .. }) => { 66 | msg.author_permissions(self.cache()).try_unwrap() 67 | } 68 | } 69 | } 70 | 71 | async fn send_ephemeral( 72 | &'ctx self, 73 | message: impl Into>, 74 | ) -> Result> { 75 | let reply = poise::CreateReply::default().content(message); 76 | let handle = self.send(reply).await?; 77 | Ok(handle) 78 | } 79 | 80 | #[cold] 81 | async fn send_error( 82 | &'ctx self, 83 | error_message: impl Into>, 84 | ) -> Result>> { 85 | let author = self.author(); 86 | let serenity_ctx = self.serenity_context(); 87 | 88 | let (name, avatar_url) = match self.guild_id() { 89 | Some(guild_id) => { 90 | if !self.author_permissions()?.embed_links() { 91 | self.send(poise::CreateReply::new() 92 | .content("An Error Occurred! Please give me embed links permissions so I can tell you more!") 93 | .ephemeral(true) 94 | ).await?; 95 | 96 | return Ok(None); 97 | } 98 | 99 | let member = match self { 100 | Self::Application(ctx) => ctx.interaction.member.as_deref().map(Cow::Borrowed), 101 | Self::Prefix(_) => { 102 | let member = guild_id.member(serenity_ctx, author.id).await; 103 | member.ok().map(Cow::Owned) 104 | } 105 | }; 106 | 107 | match member { 108 | Some(m) => (Cow::Owned(m.display_name().to_owned()), m.face()), 109 | None => (Cow::Borrowed(&*author.name), author.face()), 110 | } 111 | } 112 | None => (Cow::Borrowed(&*author.name), author.face()), 113 | }; 114 | 115 | match self 116 | .send( 117 | poise::CreateReply::default().ephemeral(true).embed( 118 | serenity::CreateEmbed::default() 119 | .colour(constants::RED) 120 | .title("An Error Occurred!") 121 | .author(serenity::CreateEmbedAuthor::new(name).icon_url(avatar_url)) 122 | .description(error_message) 123 | .footer(serenity::CreateEmbedFooter::new(format!( 124 | "Support Server: {}", 125 | self.data().config.main_server_invite 126 | ))), 127 | ), 128 | ) 129 | .await 130 | { 131 | Ok(handle) => Ok(Some(handle)), 132 | Err(_) => Ok(None), 133 | } 134 | } 135 | } 136 | 137 | pub trait SongbirdManagerExt { 138 | async fn join_vc( 139 | &self, 140 | guild_id: JoinVCToken, 141 | channel_id: serenity::ChannelId, 142 | ) -> Result>, songbird::error::JoinError>; 143 | } 144 | 145 | impl SongbirdManagerExt for songbird::Songbird { 146 | async fn join_vc( 147 | &self, 148 | JoinVCToken(guild_id, lock): JoinVCToken, 149 | channel_id: serenity::ChannelId, 150 | ) -> Result>, songbird::error::JoinError> { 151 | let _guard = lock.lock().await; 152 | match self.join(guild_id, channel_id).await { 153 | Ok(call) => Ok(call), 154 | Err(err) => { 155 | // On error, the Call is left in a semi-connected state. 156 | // We need to correct this by removing the call from the manager. 157 | drop(self.leave(guild_id).await); 158 | Err(err) 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tts_commands/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(debug_closure_helpers)] 2 | 3 | use std::borrow::Cow; 4 | 5 | use aformat::aformat; 6 | 7 | use serenity::all::{self as serenity, Mentionable as _}; 8 | 9 | use tts_core::{ 10 | constants::PREMIUM_NEUTRAL_COLOUR, 11 | opt_ext::OptionTryUnwrap as _, 12 | structs::{Command, Context, Data, FailurePoint, Result}, 13 | traits::PoiseContextExt, 14 | }; 15 | 16 | mod help; 17 | mod main_; 18 | mod other; 19 | mod owner; 20 | mod premium; 21 | mod settings; 22 | 23 | const REQUIRED_SETUP_PERMISSIONS: serenity::Permissions = 24 | serenity::Permissions::VIEW_CHANNEL.union(serenity::Permissions::SEND_MESSAGES); 25 | 26 | const REQUIRED_VC_PERMISSIONS: serenity::Permissions = serenity::Permissions::VIEW_CHANNEL 27 | .union(serenity::Permissions::CONNECT) 28 | .union(serenity::Permissions::SPEAK); 29 | 30 | #[must_use] 31 | pub fn commands() -> Vec { 32 | main_::commands() 33 | .into_iter() 34 | .chain(other::commands()) 35 | .chain(settings::commands()) 36 | .chain(premium::commands()) 37 | .chain(owner::commands()) 38 | .chain(help::commands()) 39 | .collect() 40 | } 41 | 42 | pub async fn premium_command_check(ctx: Context<'_>) -> Result { 43 | if let Context::Application(ctx) = ctx 44 | && ctx.interaction_type == poise::CommandInteractionType::Autocomplete 45 | { 46 | // Ignore the premium check during autocomplete. 47 | return Ok(true); 48 | } 49 | 50 | let data = ctx.data(); 51 | let guild_id = ctx.guild_id(); 52 | let serenity_ctx = ctx.serenity_context(); 53 | 54 | let mut main_msg = match data.premium_check(ctx.http(), guild_id).await? { 55 | None => return Ok(true), 56 | Some(FailurePoint::Guild) => { 57 | Cow::Borrowed("Hey, this is a premium command so it must be run in a server!") 58 | } 59 | Some(FailurePoint::PremiumUser) => Cow::Borrowed( 60 | "Hey, this server isn't premium, please purchase TTS Bot Premium! (`/premium`)", 61 | ), 62 | Some(FailurePoint::NotSubscribed(premium_user_id)) => { 63 | let premium_user = premium_user_id.to_user(serenity_ctx).await?; 64 | Cow::Owned(format!( 65 | concat!( 66 | "Hey, this server has a premium user setup, however they no longer have a subscription! ", 67 | "Please ask {} to renew their membership." 68 | ), 69 | premium_user.tag() 70 | )) 71 | } 72 | }; 73 | 74 | let author = ctx.author(); 75 | let guild_info = match guild_id.and_then(|g_id| serenity_ctx.cache.guild(g_id)) { 76 | Some(g) => &format!("{} | {}", g.name, g.id), 77 | None => "DMs", 78 | }; 79 | 80 | tracing::warn!( 81 | "{} | {} failed the premium check in {}", 82 | author.tag(), 83 | author.id, 84 | guild_info 85 | ); 86 | 87 | let permissions = ctx.author_permissions()?; 88 | if permissions.send_messages() { 89 | let builder = poise::CreateReply::default(); 90 | ctx.send({ 91 | const FOOTER_MSG: &str = "If this is an error, please contact GnomedDev."; 92 | if permissions.embed_links() { 93 | let embed = serenity::CreateEmbed::default() 94 | .title("TTS Bot Premium - Premium Only Command!") 95 | .description(main_msg) 96 | .colour(PREMIUM_NEUTRAL_COLOUR) 97 | .thumbnail(data.premium_avatar_url.as_str()) 98 | .footer(serenity::CreateEmbedFooter::new(FOOTER_MSG)); 99 | 100 | builder.embed(embed) 101 | } else { 102 | let main_msg = main_msg.to_mut(); 103 | main_msg.push('\n'); 104 | main_msg.push_str(FOOTER_MSG); 105 | builder.content(main_msg.as_str()) 106 | } 107 | }) 108 | .await?; 109 | } 110 | 111 | Ok(false) 112 | } 113 | 114 | pub async fn try_strip_prefix<'a>( 115 | ctx: &serenity::Context, 116 | message: &'a serenity::Message, 117 | ) -> Result> { 118 | let Some(guild_id) = message.guild_id else { 119 | if message.content.starts_with('-') { 120 | return Ok(Some(message.content.split_at("-".len()))); 121 | } 122 | return Ok(None); 123 | }; 124 | 125 | let data = ctx.data_ref::(); 126 | let row = data.guilds_db.get(guild_id.into()).await?; 127 | 128 | let prefix = row.prefix.as_str(); 129 | if message.content.starts_with(prefix) { 130 | return Ok(Some(message.content.split_at(prefix.len()))); 131 | } 132 | 133 | Ok(None) 134 | } 135 | 136 | #[cold] 137 | async fn notify_banned(ctx: Context<'_>) -> Result<()> { 138 | const BAN_MESSAGE: &str = " 139 | You have been banned from the bot. This is not reversable and is only given out in exceptional circumstances. 140 | You may have: 141 | - Committed a hate crime against the developers of the bot. 142 | - Exploited an issue in the bot to bring it down or receive premium without paying. 143 | - Broken the TTS Bot terms of service."; 144 | 145 | let author = ctx.author(); 146 | let bot_face = ctx.cache().current_user().face(); 147 | 148 | let embed = serenity::CreateEmbed::new() 149 | .author(serenity::CreateEmbedAuthor::new(author.name.as_str()).icon_url(author.face())) 150 | .thumbnail(bot_face) 151 | .colour(tts_core::constants::RED) 152 | .description(BAN_MESSAGE) 153 | .footer(serenity::CreateEmbedFooter::new( 154 | "Do not join the support server to appeal this. You are not wanted.", 155 | )); 156 | 157 | ctx.send(poise::CreateReply::default().embed(embed)).await?; 158 | Ok(()) 159 | } 160 | 161 | pub async fn command_check(ctx: Context<'_>) -> Result { 162 | if ctx.author().bot() { 163 | return Ok(false); 164 | } 165 | 166 | let data = ctx.data(); 167 | let user_row = data.userinfo_db.get(ctx.author().id.into()).await?; 168 | if user_row.bot_banned() { 169 | notify_banned(ctx).await?; 170 | return Ok(false); 171 | } 172 | 173 | let Some(guild_id) = ctx.guild_id() else { 174 | return Ok(true); 175 | }; 176 | 177 | let guild_row = data.guilds_db.get(guild_id.into()).await?; 178 | let Some(required_role) = guild_row.required_role else { 179 | return Ok(true); 180 | }; 181 | 182 | let member_roles = match ctx { 183 | Context::Application(poise::ApplicationContext { interaction, .. }) => { 184 | &interaction.member.as_deref().try_unwrap()?.roles 185 | } 186 | Context::Prefix(poise::PrefixContext { msg, .. }) => { 187 | &msg.member.as_deref().try_unwrap()?.roles 188 | } 189 | }; 190 | 191 | if member_roles.contains(&required_role) || ctx.author_permissions()?.administrator() { 192 | return Ok(true); 193 | } 194 | 195 | let msg = aformat!( 196 | "You do not have the required role to use this bot, ask a server administrator for {}.", 197 | required_role.mention() 198 | ); 199 | 200 | ctx.send_error(msg.as_str()).await?; 201 | Ok(false) 202 | } 203 | -------------------------------------------------------------------------------- /tts_core/src/database_models.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroU8; 2 | 3 | use arrayvec::ArrayString; 4 | use typesize::derive::TypeSize; 5 | 6 | use poise::serenity_prelude::{ChannelId, GuildId, RoleId, UserId}; 7 | 8 | use crate::structs::{IsPremium, TTSMode}; 9 | 10 | const MAX_VOICE_LENGTH: usize = 20; 11 | 12 | fn truncate_convert( 13 | mut s: String, 14 | field_name: &'static str, 15 | ) -> ArrayString { 16 | if s.len() > MAX_SIZE { 17 | tracing::warn!("Max size of database field {field_name} reached!"); 18 | s.truncate(MAX_SIZE); 19 | } 20 | 21 | ArrayString::from(&s).expect("Truncate to shrink to below the max size!") 22 | } 23 | 24 | pub trait Compact { 25 | type Compacted; 26 | fn compact(self) -> Self::Compacted; 27 | } 28 | 29 | #[allow(clippy::struct_excessive_bools)] 30 | #[derive(sqlx::FromRow)] 31 | pub struct GuildRowRaw { 32 | pub channel: i64, 33 | pub premium_user: Option, 34 | pub required_role: Option, 35 | pub xsaid: bool, 36 | pub auto_join: bool, 37 | pub bot_ignore: bool, 38 | pub skip_emoji: bool, 39 | pub to_translate: bool, 40 | pub require_voice: bool, 41 | pub text_in_voice: bool, 42 | pub audience_ignore: bool, 43 | pub msg_length: i16, 44 | pub repeated_chars: i16, 45 | pub prefix: String, 46 | pub target_lang: Option, 47 | pub required_prefix: Option, 48 | pub voice_mode: TTSMode, 49 | } 50 | 51 | #[bool_to_bitflags::bool_to_bitflags(owning_setters)] 52 | #[derive(Debug, Clone, Copy, typesize::derive::TypeSize)] 53 | pub struct GuildRow { 54 | pub channel: Option, 55 | pub premium_user: Option, 56 | pub required_role: Option, 57 | pub xsaid: bool, 58 | pub auto_join: bool, 59 | pub bot_ignore: bool, 60 | pub skip_emoji: bool, 61 | pub to_translate: bool, 62 | pub require_voice: bool, 63 | pub text_in_voice: bool, 64 | pub audience_ignore: bool, 65 | pub msg_length: u16, 66 | pub repeated_chars: Option, 67 | pub prefix: ArrayString<8>, 68 | pub target_lang: Option>, 69 | pub required_prefix: Option>, 70 | pub voice_mode: TTSMode, 71 | } 72 | 73 | impl GuildRow { 74 | #[must_use] 75 | pub fn target_lang(&self, is_premium: IsPremium) -> Option<&str> { 76 | if let Some(target_lang) = &self.target_lang 77 | && self.to_translate() 78 | && is_premium.into() 79 | { 80 | Some(target_lang.as_str()) 81 | } else { 82 | None 83 | } 84 | } 85 | } 86 | 87 | impl Compact for GuildRowRaw { 88 | type Compacted = GuildRow; 89 | fn compact(self) -> Self::Compacted { 90 | Self::Compacted { 91 | __generated_flags: GuildRowGeneratedFlags::empty(), 92 | channel: (self.channel != 0).then(|| ChannelId::new(self.channel as u64)), 93 | premium_user: self.premium_user.map(|id| UserId::new(id as u64)), 94 | required_role: self.required_role.map(|id| RoleId::new(id as u64)), 95 | msg_length: self.msg_length as u16, 96 | repeated_chars: NonZeroU8::new(self.repeated_chars as u8), 97 | prefix: truncate_convert(self.prefix, "guild.prefix"), 98 | target_lang: self 99 | .target_lang 100 | .map(|t| truncate_convert(t, "guild.target_lang")), 101 | required_prefix: self 102 | .required_prefix 103 | .map(|t| truncate_convert(t, "guild.required_prefix")), 104 | voice_mode: self.voice_mode, 105 | } 106 | .set_xsaid(self.xsaid) 107 | .set_auto_join(self.auto_join) 108 | .set_bot_ignore(self.bot_ignore) 109 | .set_skip_emoji(self.skip_emoji) 110 | .set_to_translate(self.to_translate) 111 | .set_require_voice(self.require_voice) 112 | .set_text_in_voice(self.text_in_voice) 113 | .set_audience_ignore(self.audience_ignore) 114 | } 115 | } 116 | 117 | #[derive(sqlx::FromRow, Clone, Copy)] 118 | #[expect( 119 | clippy::struct_excessive_bools, 120 | reason = "raw version of compacted type" 121 | )] 122 | pub struct UserRowRaw { 123 | pub dm_blocked: bool, 124 | pub dm_welcomed: bool, 125 | pub bot_banned: bool, 126 | pub use_new_formatting: bool, 127 | pub voice_mode: Option, 128 | pub premium_voice_mode: Option, 129 | } 130 | 131 | #[bool_to_bitflags::bool_to_bitflags(owning_setters)] 132 | #[derive(Debug, Clone, Copy, typesize::derive::TypeSize)] 133 | pub struct UserRow { 134 | pub dm_blocked: bool, 135 | pub dm_welcomed: bool, 136 | pub bot_banned: bool, 137 | pub use_new_formatting: bool, 138 | pub voice_mode: Option, 139 | pub premium_voice_mode: Option, 140 | } 141 | 142 | impl Compact for UserRowRaw { 143 | type Compacted = UserRow; 144 | fn compact(self) -> Self::Compacted { 145 | Self::Compacted { 146 | voice_mode: self.voice_mode, 147 | premium_voice_mode: self.premium_voice_mode, 148 | __generated_flags: UserRowGeneratedFlags::empty(), 149 | } 150 | .set_dm_blocked(self.dm_blocked) 151 | .set_dm_welcomed(self.dm_welcomed) 152 | .set_bot_banned(self.bot_banned) 153 | .set_use_new_formatting(self.use_new_formatting) 154 | } 155 | } 156 | 157 | #[derive(sqlx::FromRow)] 158 | pub struct GuildVoiceRowRaw { 159 | pub guild_id: i64, 160 | pub mode: TTSMode, 161 | pub voice: String, 162 | } 163 | 164 | #[derive(Debug, Clone, Copy, TypeSize)] 165 | 166 | pub struct GuildVoiceRow { 167 | pub guild_id: Option, 168 | pub mode: TTSMode, 169 | pub voice: ArrayString, 170 | } 171 | 172 | impl Compact for GuildVoiceRowRaw { 173 | type Compacted = GuildVoiceRow; 174 | fn compact(self) -> Self::Compacted { 175 | Self::Compacted { 176 | guild_id: (self.guild_id != 0).then(|| GuildId::new(self.guild_id as u64)), 177 | mode: self.mode, 178 | voice: truncate_convert(self.voice, "guildvoicerow.voice"), 179 | } 180 | } 181 | } 182 | 183 | #[derive(sqlx::FromRow)] 184 | pub struct UserVoiceRowRaw { 185 | pub user_id: i64, 186 | pub mode: TTSMode, 187 | pub voice: Option, 188 | pub speaking_rate: Option, 189 | } 190 | 191 | #[derive(Debug, Clone, Copy, TypeSize)] 192 | pub struct UserVoiceRow { 193 | pub user_id: Option, 194 | pub mode: TTSMode, 195 | pub voice: Option>, 196 | pub speaking_rate: Option, 197 | } 198 | 199 | impl Compact for UserVoiceRowRaw { 200 | type Compacted = UserVoiceRow; 201 | fn compact(self) -> Self::Compacted { 202 | Self::Compacted { 203 | user_id: (self.user_id != 0).then(|| UserId::new(self.user_id as u64)), 204 | mode: self.mode, 205 | voice: self 206 | .voice 207 | .map(|v| truncate_convert(v, "uservoicerow.voice")), 208 | speaking_rate: self.speaking_rate, 209 | } 210 | } 211 | } 212 | 213 | #[derive(Debug, TypeSize, sqlx::FromRow)] 214 | pub struct NicknameRow { 215 | pub name: Option, 216 | } 217 | 218 | pub type NicknameRowRaw = NicknameRow; 219 | 220 | impl Compact for NicknameRowRaw { 221 | type Compacted = NicknameRow; 222 | fn compact(self) -> Self::Compacted { 223 | self 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /tts_core/src/database.rs: -------------------------------------------------------------------------------- 1 | use std::{hash::Hash, sync::Arc}; 2 | 3 | use dashmap::DashMap; 4 | use typesize::TypeSize; 5 | 6 | pub use crate::database_models::*; 7 | use crate::structs::{Result, TTSMode}; 8 | 9 | type PgArguments<'a> = ::Arguments<'a>; 10 | type QueryAs<'a, R> = sqlx::query::QueryAs<'a, sqlx::Postgres, R, PgArguments<'a>>; 11 | type Query<'a> = sqlx::query::Query<'a, sqlx::Postgres, PgArguments<'a>>; 12 | 13 | pub trait CacheKeyTrait: std::cmp::Eq + Hash { 14 | fn bind_query(self, query: Query<'_>) -> Query<'_>; 15 | fn bind_query_as(self, query: QueryAs<'_, R>) -> QueryAs<'_, R>; 16 | } 17 | 18 | impl CacheKeyTrait for i64 { 19 | fn bind_query(self, query: Query<'_>) -> Query<'_> { 20 | query.bind(self) 21 | } 22 | fn bind_query_as(self, query: QueryAs<'_, R>) -> QueryAs<'_, R> { 23 | query.bind(self) 24 | } 25 | } 26 | 27 | impl CacheKeyTrait for [i64; 2] { 28 | fn bind_query(self, query: Query<'_>) -> Query<'_> { 29 | query.bind(self[0]).bind(self[1]) 30 | } 31 | fn bind_query_as(self, query: QueryAs<'_, R>) -> QueryAs<'_, R> { 32 | query.bind(self[0]).bind(self[1]) 33 | } 34 | } 35 | 36 | impl CacheKeyTrait for (i64, TTSMode) { 37 | fn bind_query(self, query: Query<'_>) -> Query<'_> { 38 | query.bind(self.0).bind(self.1) 39 | } 40 | fn bind_query_as(self, query: QueryAs<'_, R>) -> QueryAs<'_, R> { 41 | query.bind(self.0).bind(self.1) 42 | } 43 | } 44 | 45 | type OwnedArc = typesize::ptr::SizableArc; 46 | 47 | pub struct Handler { 48 | pool: sqlx::PgPool, 49 | cache: DashMap>, 50 | 51 | default_row: Arc, 52 | single_insert: &'static str, 53 | create_row: &'static str, 54 | select: &'static str, 55 | delete: &'static str, 56 | } 57 | 58 | impl Handler 59 | where 60 | CacheKey: CacheKeyTrait + Sync + Send + Copy + Default, 61 | RowT: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Compact + Send + Unpin, 62 | { 63 | pub async fn new( 64 | pool: sqlx::PgPool, 65 | select: &'static str, 66 | delete: &'static str, 67 | create_row: &'static str, 68 | single_insert: &'static str, 69 | ) -> Result { 70 | let default_row = Self::_get(&pool, CacheKey::default(), select) 71 | .await? 72 | .expect("Default row not in table!"); 73 | 74 | println!("Loaded default row for table with select: {select}"); 75 | Ok(Self { 76 | cache: DashMap::new(), 77 | default_row, 78 | pool, 79 | select, 80 | delete, 81 | create_row, 82 | single_insert, 83 | }) 84 | } 85 | 86 | async fn _get( 87 | pool: &sqlx::PgPool, 88 | key: CacheKey, 89 | select: &'static str, 90 | ) -> Result>> { 91 | let query = key.bind_query_as(sqlx::query_as(select)); 92 | let row: Option = query.fetch_optional(pool).await?; 93 | Ok(row.map(Compact::compact).map(Arc::new)) 94 | } 95 | 96 | pub async fn get(&self, identifier: CacheKey) -> Result> { 97 | if let Some(row) = self.cache.get(&identifier) { 98 | return Ok(row.clone()); 99 | } 100 | 101 | let row = Self::_get(&self.pool, identifier, self.select) 102 | .await? 103 | .unwrap_or_else(|| self.default_row.clone()); 104 | 105 | self.cache.insert(identifier, row.clone().into()); 106 | Ok(row) 107 | } 108 | 109 | pub async fn create_row(&self, identifier: CacheKey) -> Result<()> { 110 | identifier 111 | .bind_query(sqlx::query(self.create_row)) 112 | .execute(&self.pool) 113 | .await?; 114 | 115 | Ok(()) 116 | } 117 | 118 | pub async fn set_one( 119 | &self, 120 | identifier: CacheKey, 121 | key: &'static str, 122 | value: Val, 123 | ) -> Result<()> 124 | where 125 | for<'a> Val: sqlx::Encode<'a, sqlx::Postgres>, 126 | Val: sqlx::Type, 127 | Val: Sync + Send, 128 | { 129 | let query_raw = self.single_insert.replace("{key}", key); 130 | 131 | identifier 132 | .bind_query(sqlx::query(&query_raw)) 133 | .bind(value) 134 | .execute(&self.pool) 135 | .await?; 136 | 137 | self.invalidate_cache(&identifier); 138 | Ok(()) 139 | } 140 | 141 | pub async fn delete(&self, identifier: CacheKey) -> Result<()> { 142 | identifier 143 | .bind_query(sqlx::query(self.delete)) 144 | .execute(&self.pool) 145 | .await?; 146 | 147 | self.invalidate_cache(&identifier); 148 | Ok(()) 149 | } 150 | 151 | pub fn invalidate_cache(&self, identifier: &CacheKey) { 152 | self.cache.remove(identifier); 153 | } 154 | } 155 | 156 | impl TypeSize for Handler 157 | where 158 | RowT::Compacted: TypeSize, 159 | { 160 | fn extra_size(&self) -> usize { 161 | self.cache.extra_size() 162 | } 163 | 164 | typesize::if_typesize_details! { 165 | fn get_collection_item_count(&self) -> Option { 166 | self.cache.get_collection_item_count() 167 | } 168 | 169 | fn get_size_details(&self) -> Vec { 170 | self.cache.get_size_details() 171 | } 172 | } 173 | } 174 | 175 | #[macro_export] 176 | macro_rules! create_db_handler { 177 | ($pool:expr, $table_name:literal, $id_name:literal) => {{ 178 | const TABLE_NAME: &str = $table_name; 179 | const ID_NAME: &str = $id_name; 180 | 181 | database::Handler::new( 182 | $pool, 183 | const_format::formatcp!("SELECT * FROM {TABLE_NAME} WHERE {ID_NAME} = $1"), 184 | const_format::formatcp!("DELETE FROM {TABLE_NAME} WHERE {ID_NAME} = $1"), 185 | const_format::formatcp!( 186 | "INSERT INTO {TABLE_NAME}({ID_NAME}) VALUES ($1) 187 | ON CONFLICT ({ID_NAME}) DO NOTHING" 188 | ), 189 | const_format::formatcp!( 190 | "INSERT INTO {TABLE_NAME}({ID_NAME}, {{key}}) VALUES ($1, $2) 191 | ON CONFLICT ({ID_NAME}) DO UPDATE SET {{key}} = $2" 192 | ), 193 | ) 194 | }}; 195 | ($pool:expr, $table_name:literal, $id_name1:literal, $id_name2:literal) => {{ 196 | const TABLE_NAME: &str = $table_name; 197 | const ID_NAME1: &str = $id_name1; 198 | const ID_NAME2: &str = $id_name2; 199 | 200 | database::Handler::new( 201 | $pool, 202 | const_format::formatcp!( 203 | "SELECT * FROM {TABLE_NAME} WHERE {ID_NAME1} = $1 AND {ID_NAME2} = $2" 204 | ), 205 | const_format::formatcp!( 206 | "DELETE FROM {TABLE_NAME} WHERE {ID_NAME1} = $1 AND {ID_NAME2} = $2" 207 | ), 208 | const_format::formatcp!( 209 | "INSERT INTO {TABLE_NAME}({ID_NAME1}, {ID_NAME2}) VALUES ($1, $2) 210 | ON CONFLICT ({ID_NAME1}, {ID_NAME2}) DO NOTHING" 211 | ), 212 | const_format::formatcp!( 213 | "INSERT INTO {TABLE_NAME}({ID_NAME1}, {ID_NAME2}, {{key}}) VALUES ($1, $2, $3) 214 | ON CONFLICT ({ID_NAME1}, {ID_NAME2}) DO UPDATE SET {{key}} = $3" 215 | ), 216 | ) 217 | }}; 218 | } 219 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::BTreeMap, 3 | sync::{Arc, atomic::AtomicBool}, 4 | time::Duration, 5 | }; 6 | 7 | use anyhow::Ok; 8 | use parking_lot::Mutex; 9 | 10 | use poise::serenity_prelude as serenity; 11 | use serenity::small_fixed_array::FixedString; 12 | 13 | use tts_core::{ 14 | analytics, create_db_handler, database, 15 | structs::{Data, PollyVoice, RegexCache, Result, TTSMode}, 16 | }; 17 | use tts_events::EventHandler; 18 | use tts_tasks::Looper as _; 19 | 20 | mod startup; 21 | 22 | use startup::*; 23 | 24 | #[global_allocator] 25 | static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; 26 | 27 | fn main() -> Result<()> { 28 | // SAFETY: No other threads have been spawned. 29 | unsafe { std::env::set_var("RUST_LIB_BACKTRACE", "1") }; 30 | 31 | let start_time = std::time::SystemTime::now(); 32 | 33 | println!("Starting tokio runtime"); 34 | tokio::runtime::Builder::new_multi_thread() 35 | .enable_all() 36 | .build()? 37 | .block_on(main_(start_time)) 38 | } 39 | 40 | async fn main_(start_time: std::time::SystemTime) -> Result<()> { 41 | println!("Loading and performing migrations"); 42 | let (pool, config) = tts_migrations::load_db_and_conf().await?; 43 | 44 | println!("Initialising Http client"); 45 | let reqwest = reqwest::Client::new(); 46 | let auth_key = config.main.tts_service_auth_key.as_deref(); 47 | 48 | let token = config.main.token.clone(); 49 | let mut http_builder = serenity::HttpBuilder::new(token.clone()); 50 | if let Some(proxy) = &config.main.proxy_url { 51 | println!("Connecting via proxy"); 52 | http_builder = http_builder 53 | .proxy(proxy.as_str()) 54 | .ratelimiter_disabled(true); 55 | } 56 | 57 | let http = Arc::new(http_builder.build()); 58 | 59 | println!("Performing big startup join"); 60 | let tts_service = || config.main.tts_service.clone(); 61 | let ( 62 | webhooks, 63 | guilds_db, 64 | userinfo_db, 65 | user_voice_db, 66 | guild_voice_db, 67 | nickname_db, 68 | gtts_voices, 69 | espeak_voices, 70 | gcloud_voices, 71 | polly_voices, 72 | translation_languages, 73 | shard_count, 74 | premium_user, 75 | ) = tokio::try_join!( 76 | get_webhooks(&http, config.webhooks), 77 | create_db_handler!(pool.clone(), "guilds", "guild_id"), 78 | create_db_handler!(pool.clone(), "userinfo", "user_id"), 79 | create_db_handler!(pool.clone(), "user_voice", "user_id", "mode"), 80 | create_db_handler!(pool.clone(), "guild_voice", "guild_id", "mode"), 81 | create_db_handler!(pool.clone(), "nicknames", "guild_id", "user_id"), 82 | fetch_voices(&reqwest, tts_service(), auth_key, TTSMode::gTTS), 83 | fetch_voices(&reqwest, tts_service(), auth_key, TTSMode::eSpeak), 84 | fetch_voices(&reqwest, tts_service(), auth_key, TTSMode::gCloud), 85 | fetch_voices::>(&reqwest, tts_service(), auth_key, TTSMode::Polly), 86 | fetch_translation_languages(&reqwest, tts_service(), auth_key), 87 | async { Ok(http.get_bot_gateway().await?.shards) }, 88 | async { 89 | let res = serenity::UserId::new(802632257658683442) 90 | .to_user(&http) 91 | .await?; 92 | 93 | println!("Loaded premium user"); 94 | Ok(res) 95 | } 96 | )?; 97 | 98 | println!("Setting up webhook logging"); 99 | tts_tasks::logging::WebhookLogger::init( 100 | http.clone(), 101 | webhooks.logs.clone(), 102 | webhooks.errors.clone(), 103 | ); 104 | 105 | println!("Sending startup message"); 106 | let startup_message = send_startup_message(&http, &webhooks.logs).await?; 107 | 108 | println!("Spawning analytics handler"); 109 | let analytics = Arc::new(analytics::Handler::new(pool.clone())); 110 | tokio::spawn(analytics.clone().start()); 111 | 112 | let data = Arc::new(Data { 113 | pool, 114 | system_info: Mutex::new(sysinfo::System::new()), 115 | bot_list_tokens: Mutex::new(config.bot_list_tokens), 116 | 117 | fully_started: AtomicBool::new(false), 118 | join_vc_tokens: dashmap::DashMap::new(), 119 | songbird: songbird::Songbird::serenity(), 120 | last_to_xsaid_tracker: dashmap::DashMap::new(), 121 | update_startup_lock: tokio::sync::Mutex::new(()), 122 | entitlement_cache: mini_moka::sync::Cache::builder() 123 | .time_to_live(Duration::from_secs(60 * 60)) 124 | .build(), 125 | 126 | gtts_voices, 127 | espeak_voices, 128 | translation_languages, 129 | gcloud_voices: prepare_gcloud_voices(gcloud_voices), 130 | polly_voices: polly_voices 131 | .into_iter() 132 | .map(|v| (v.id.clone(), v)) 133 | .collect::>(), 134 | 135 | config: config.main, 136 | premium_config: config.premium, 137 | website_info: Mutex::new(config.website_info), 138 | reqwest, 139 | premium_avatar_url: FixedString::from_string_trunc(premium_user.face()), 140 | analytics, 141 | webhooks, 142 | start_time, 143 | startup_message, 144 | regex_cache: RegexCache::new()?, 145 | guilds_db, 146 | userinfo_db, 147 | nickname_db, 148 | user_voice_db, 149 | guild_voice_db, 150 | }); 151 | 152 | let framework_options = poise::FrameworkOptions { 153 | commands: tts_commands::commands(), 154 | on_error: |error| { 155 | Box::pin(async move { 156 | let res = tts_core::errors::handle(error).await; 157 | res.unwrap_or_else(|err| tracing::error!("on_error: {:?}", err)); 158 | }) 159 | }, 160 | allowed_mentions: Some( 161 | serenity::CreateAllowedMentions::default() 162 | .replied_user(true) 163 | .all_users(true), 164 | ), 165 | pre_command: analytics::pre_command, 166 | prefix_options: poise::PrefixFrameworkOptions { 167 | stripped_dynamic_prefix: Some(|ctx, message, _| { 168 | Box::pin(tts_commands::try_strip_prefix(ctx, message)) 169 | }), 170 | ..poise::PrefixFrameworkOptions::default() 171 | }, 172 | command_check: Some(|ctx| Box::pin(tts_commands::command_check(ctx))), 173 | ..poise::FrameworkOptions::default() 174 | }; 175 | 176 | let mut client = serenity::ClientBuilder::new_with_http(token, http, tts_events::get_intents()) 177 | .voice_manager::(data.songbird.clone()) 178 | .framework(poise::Framework::new(framework_options)) 179 | .event_handler::(EventHandler) 180 | .data(data as _) 181 | .await?; 182 | 183 | let shutdown_trigger = client.shard_manager.get_shutdown_trigger(); 184 | tokio::spawn(async move { 185 | wait_until_shutdown().await; 186 | 187 | tracing::warn!("Recieved control C and shutting down."); 188 | shutdown_trigger(); 189 | }); 190 | 191 | client 192 | .start_shards(shard_count.get()) 193 | .await 194 | .map_err(Into::into) 195 | } 196 | 197 | #[cfg(unix)] 198 | async fn wait_until_shutdown() { 199 | use tokio::signal::unix as signal; 200 | 201 | let [mut s1, mut s2, mut s3] = [ 202 | signal::signal(signal::SignalKind::hangup()).unwrap(), 203 | signal::signal(signal::SignalKind::interrupt()).unwrap(), 204 | signal::signal(signal::SignalKind::terminate()).unwrap(), 205 | ]; 206 | 207 | tokio::select!( 208 | v = s1.recv() => v.unwrap(), 209 | v = s2.recv() => v.unwrap(), 210 | v = s3.recv() => v.unwrap(), 211 | ); 212 | } 213 | 214 | #[cfg(windows)] 215 | async fn wait_until_shutdown() { 216 | let (mut s1, mut s2) = ( 217 | tokio::signal::windows::ctrl_c().unwrap(), 218 | tokio::signal::windows::ctrl_break().unwrap(), 219 | ); 220 | 221 | tokio::select!( 222 | v = s1.recv() => v.unwrap(), 223 | v = s2.recv() => v.unwrap(), 224 | ); 225 | } 226 | -------------------------------------------------------------------------------- /tts_commands/src/settings/setup.rs: -------------------------------------------------------------------------------- 1 | use aformat::{ToArrayString, aformat, astr}; 2 | use anyhow::bail; 3 | 4 | use poise::serenity_prelude as serenity; 5 | use serenity::{ 6 | CollectComponentInteractions, ComponentInteractionDataKind, Permissions, builder::*, 7 | small_fixed_array::FixedString, 8 | }; 9 | 10 | use tts_core::{ 11 | common::{confirm_dialog, random_footer}, 12 | opt_ext::OptionTryUnwrap as _, 13 | require, require_guild, 14 | structs::{CommandResult, Context, Result}, 15 | }; 16 | 17 | use crate::REQUIRED_SETUP_PERMISSIONS; 18 | 19 | fn can_send_generic(permissions: Permissions) -> bool { 20 | (REQUIRED_SETUP_PERMISSIONS - permissions).is_empty() 21 | } 22 | 23 | fn can_send( 24 | guild: &serenity::Guild, 25 | channel: &serenity::GuildChannel, 26 | member: &serenity::Member, 27 | ) -> bool { 28 | can_send_generic(guild.user_permissions_in(channel, member)) 29 | } 30 | 31 | fn can_send_partial( 32 | guild: &serenity::Guild, 33 | channel: &serenity::GuildChannel, 34 | user_id: serenity::UserId, 35 | partial_member: &serenity::PartialMember, 36 | ) -> bool { 37 | can_send_generic(guild.partial_member_permissions_in(channel, user_id, partial_member)) 38 | } 39 | 40 | struct ChannelMenuEntry { 41 | id: serenity::ChannelId, 42 | id_str: ::ArrayString, 43 | name: FixedString, 44 | position: u16, 45 | has_webhook_perms: bool, 46 | } 47 | 48 | fn get_eligible_channels( 49 | ctx: Context<'_>, 50 | bot_member: &serenity::Member, 51 | ) -> Result>> { 52 | let guild = require_guild!(ctx, Ok(None)); 53 | let author_can_send: &dyn Fn(_) -> _ = match ctx { 54 | Context::Application(poise::ApplicationContext { interaction, .. }) => { 55 | let author_member = &interaction.member.as_deref().try_unwrap()?; 56 | &|c| can_send(&guild, c, author_member) 57 | } 58 | Context::Prefix(poise::PrefixContext { msg, .. }) => { 59 | let author_member = msg.member.as_deref().try_unwrap()?; 60 | &|c| can_send_partial(&guild, c, msg.author.id, author_member) 61 | } 62 | }; 63 | 64 | let channels = guild 65 | .channels 66 | .iter() 67 | .filter(|c| { 68 | c.base.kind == serenity::ChannelType::Text 69 | && can_send(&guild, c, bot_member) 70 | && author_can_send(c) 71 | }) 72 | .map(|c| ChannelMenuEntry { 73 | id: c.id, 74 | id_str: c.id.to_arraystring(), 75 | name: c.base.name.clone(), 76 | position: c.position, 77 | has_webhook_perms: guild.user_permissions_in(c, bot_member).manage_webhooks(), 78 | }) 79 | .collect(); 80 | 81 | Ok(Some(channels)) 82 | } 83 | 84 | async fn show_channel_select_menu( 85 | ctx: Context<'_>, 86 | bot_member: &serenity::Member, 87 | ) -> Result> { 88 | let Some(mut text_channels) = get_eligible_channels(ctx, bot_member)? else { 89 | return Ok(None); 90 | }; 91 | 92 | if text_channels.is_empty() { 93 | ctx.say("**Error**: This server doesn't have any text channels that we both have Read/Send Messages in!").await?; 94 | return Ok(None); 95 | } else if text_channels.len() >= (25 * 5) { 96 | ctx.say("**Error**: This server has too many text channels to show in a menu! Please run `/setup #channel`").await?; 97 | return Ok(None); 98 | } 99 | 100 | text_channels.sort_by(|c1, c2| Ord::cmp(&c1.position, &c2.position)); 101 | 102 | let builder = poise::CreateReply::default() 103 | .content("Select a channel!") 104 | .components(generate_channel_select(&text_channels)); 105 | 106 | let reply = ctx.send(builder).await?; 107 | let reply_message = reply.message().await?; 108 | let interaction = reply_message 109 | .id 110 | .collect_component_interactions(ctx.serenity_context()) 111 | .timeout(std::time::Duration::from_secs(60 * 5)) 112 | .author_id(ctx.author().id) 113 | .await; 114 | 115 | let Some(interaction) = interaction else { 116 | // The timeout was hit 117 | return Ok(None); 118 | }; 119 | 120 | interaction.defer(ctx.http()).await?; 121 | 122 | let ComponentInteractionDataKind::StringSelect { values } = &interaction.data.kind else { 123 | bail!("Expected a string value") 124 | }; 125 | 126 | let selected_id: serenity::ChannelId = values[0].parse()?; 127 | let selected_entry = text_channels 128 | .into_iter() 129 | .find(|entry| entry.id == selected_id) 130 | .unwrap(); 131 | 132 | Ok(Some((selected_id, selected_entry.has_webhook_perms))) 133 | } 134 | 135 | fn generate_channel_select(text_channels: &[ChannelMenuEntry]) -> Vec> { 136 | text_channels 137 | .chunks(25) 138 | .enumerate() 139 | .map(|(i, chunked_channels)| { 140 | CreateComponent::ActionRow(CreateActionRow::SelectMenu(CreateSelectMenu::new( 141 | format!("select::channels::{i}"), 142 | CreateSelectMenuKind::String { 143 | options: chunked_channels 144 | .iter() 145 | .map(|entry| CreateSelectMenuOption::new(&*entry.name, &*entry.id_str)) 146 | .collect(), 147 | }, 148 | ))) 149 | }) 150 | .collect::>() 151 | } 152 | 153 | /// Setup the bot to read messages from the given channel 154 | #[poise::command( 155 | guild_only, 156 | category = "Settings", 157 | prefix_command, 158 | slash_command, 159 | required_permissions = "ADMINISTRATOR", 160 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 161 | )] 162 | 163 | pub async fn setup( 164 | ctx: Context<'_>, 165 | #[description = "The channel for the bot to read messages from"] 166 | #[channel_types("Text")] 167 | channel: Option, 168 | ) -> CommandResult { 169 | let data = ctx.data(); 170 | let author = ctx.author(); 171 | let guild_id = ctx.guild_id().unwrap(); 172 | 173 | let (bot_user_id, ref bot_user_name, bot_user_face) = { 174 | let current_user = ctx.cache().current_user(); 175 | ( 176 | current_user.id, 177 | current_user.name.clone(), 178 | current_user.face(), 179 | ) 180 | }; 181 | 182 | let (channel_id, has_webhook_perms) = { 183 | let bot_member = guild_id.member(ctx, bot_user_id).await?; 184 | let (channel, has_webhook_perms) = if let Some(channel) = channel { 185 | let chan_perms = require_guild!(ctx).user_permissions_in(&channel, &bot_member); 186 | (channel.id, chan_perms.manage_webhooks()) 187 | } else { 188 | require!(show_channel_select_menu(ctx, &bot_member).await?, Ok(())) 189 | }; 190 | 191 | (channel, has_webhook_perms) 192 | }; 193 | 194 | data.guilds_db 195 | .set_one(guild_id.into(), "channel", &(channel_id.get() as i64)) 196 | .await?; 197 | ctx.send( 198 | poise::CreateReply::default().embed( 199 | serenity::CreateEmbed::default() 200 | .title(aformat!("{bot_user_name} has been setup!").as_str()) 201 | .thumbnail(&bot_user_face) 202 | .description( 203 | aformat!( 204 | "TTS Bot will now accept commands and read from <#{channel_id}>.\n{}" 205 | astr!("Just do `/join` and start talking!") 206 | ) 207 | .as_str(), 208 | ) 209 | .footer(serenity::CreateEmbedFooter::new(random_footer( 210 | &data.config.main_server_invite, 211 | bot_user_id, 212 | ))) 213 | .author(serenity::CreateEmbedAuthor::new(&*author.name).icon_url(author.face())), 214 | ), 215 | ) 216 | .await?; 217 | 218 | let poise::Context::Application(_) = ctx else { 219 | return Ok(()); 220 | }; 221 | 222 | if !has_webhook_perms { 223 | return Ok(()); 224 | } 225 | 226 | let Some(confirmed) = confirm_dialog( 227 | ctx, 228 | "Would you like to set up TTS Bot update announcements for the setup channel?", 229 | "Yes", 230 | "No", 231 | ) 232 | .await? 233 | else { 234 | return Ok(()); 235 | }; 236 | 237 | let reply = if confirmed { 238 | let announcements = data.config.announcements_channel; 239 | announcements.follow(ctx.http(), channel_id).await?; 240 | 241 | "Set up update announcements in this channel!" 242 | } else { 243 | "Okay, didn't set up update announcements." 244 | }; 245 | 246 | ctx.send(poise::CreateReply::default().content(reply).ephemeral(true)) 247 | .await?; 248 | 249 | Ok(()) 250 | } 251 | -------------------------------------------------------------------------------- /tts_commands/src/help.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Write, ops::ControlFlow}; 2 | 3 | use arrayvec::ArrayVec; 4 | use indexmap::IndexMap; 5 | 6 | use self::serenity::CreateEmbed; 7 | use poise::serenity_prelude as serenity; 8 | 9 | use tts_core::{ 10 | require, 11 | structs::{ApplicationContext, Command, CommandResult, Context}, 12 | traits::PoiseContextExt, 13 | }; 14 | 15 | enum HelpCommandMode<'a> { 16 | Root, 17 | Group(&'a Command), 18 | Command(&'a Command), 19 | } 20 | 21 | fn get_command_mapping(commands: &[Command]) -> IndexMap<&str, Vec<&Command>> { 22 | let mut mapping = IndexMap::new(); 23 | 24 | for command in commands { 25 | if !command.hide_in_help { 26 | let category = command.category.as_deref().unwrap_or("Uncategoried"); 27 | mapping 28 | .entry(category) 29 | .or_insert_with(Vec::new) 30 | .push(command); 31 | } 32 | } 33 | 34 | mapping 35 | } 36 | 37 | fn format_params(buf: &mut String, command: &Command) { 38 | for p in &command.parameters { 39 | let name = &p.name; 40 | if p.required { 41 | write!(buf, " <{name}>").unwrap(); 42 | } else { 43 | write!(buf, " [{name}]").unwrap(); 44 | } 45 | } 46 | } 47 | 48 | fn show_group_description(group: &IndexMap<&str, Vec<&Command>>) -> String { 49 | let mut buf = String::with_capacity(group.len()); // Major underestimation, but it's better than nothing 50 | for (category, commands) in group { 51 | writeln!(buf, "**__{category}__**").unwrap(); 52 | for c in commands { 53 | let name = &c.qualified_name; 54 | let description = c.description.as_deref().unwrap_or("no description"); 55 | 56 | write!(buf, "`/{name}").unwrap(); 57 | format_params(&mut buf, c); 58 | writeln!(buf, "`: {description}").unwrap(); 59 | } 60 | } 61 | 62 | buf 63 | } 64 | 65 | #[expect(clippy::unused_async)] 66 | pub async fn autocomplete<'a>( 67 | ctx: ApplicationContext<'a>, 68 | searching: &'a str, 69 | ) -> serenity::CreateAutocompleteResponse<'a> { 70 | fn flatten_commands<'a>( 71 | result: &mut ArrayVec, 25>, 72 | commands: &'a [Command], 73 | searching: &str, 74 | ) -> ControlFlow<()> { 75 | for command in commands { 76 | if command.owners_only || command.hide_in_help { 77 | continue; 78 | } 79 | 80 | if command.subcommands.is_empty() { 81 | if command.qualified_name.starts_with(searching) { 82 | let choice = serenity::AutocompleteChoice::new( 83 | command.qualified_name.as_ref(), 84 | command.qualified_name.as_ref(), 85 | ); 86 | 87 | if result.try_push(choice).is_err() { 88 | return ControlFlow::Break(()); 89 | } 90 | } 91 | } else { 92 | flatten_commands(result, &command.subcommands, searching)?; 93 | } 94 | } 95 | 96 | ControlFlow::Continue(()) 97 | } 98 | 99 | let commands = &ctx.framework.options().commands; 100 | let mut result = ArrayVec::<_, 25>::new(); 101 | 102 | let _ = flatten_commands(&mut result, commands, searching); 103 | 104 | result.sort_by_cached_key(|a| strsim::levenshtein(&a.name, searching)); 105 | serenity::CreateAutocompleteResponse::new().set_choices(result.into_iter().collect::>()) 106 | } 107 | 108 | /// Shows TTS Bot's commands and descriptions of them 109 | #[poise::command( 110 | prefix_command, 111 | slash_command, 112 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 113 | )] 114 | async fn help( 115 | ctx: Context<'_>, 116 | #[rest] 117 | #[description = "The command to get help with"] 118 | #[autocomplete = "autocomplete"] 119 | command: Option, 120 | ) -> CommandResult { 121 | command_func(ctx, command.as_deref()).await 122 | } 123 | 124 | pub async fn command_func(ctx: Context<'_>, command: Option<&str>) -> CommandResult { 125 | let framework_options = ctx.framework().options(); 126 | let commands = &framework_options.commands; 127 | 128 | let remaining_args: String; 129 | let mode = match command { 130 | None => HelpCommandMode::Root, 131 | Some(command) => { 132 | let mut subcommand_iterator = command.split(' '); 133 | 134 | let top_level_command = subcommand_iterator.next().unwrap(); 135 | let Some((mut command_obj, _, _)) = 136 | poise::find_command(commands, top_level_command, true, &mut Vec::new()) 137 | else { 138 | let msg = format!("No command called {top_level_command} found!"); 139 | ctx.say(msg).await?; 140 | return Ok(()); 141 | }; 142 | 143 | remaining_args = subcommand_iterator.collect(); 144 | if !remaining_args.is_empty() { 145 | (command_obj, _, _) = require!( 146 | poise::find_command( 147 | &command_obj.subcommands, 148 | &remaining_args, 149 | true, 150 | &mut Vec::new() 151 | ), 152 | { 153 | let group_name = &command_obj.name; 154 | let msg = format!( 155 | "The group {group_name} does not have a subcommand called {remaining_args}!" 156 | ); 157 | 158 | ctx.say(msg).await?; 159 | Ok(()) 160 | } 161 | ); 162 | } 163 | 164 | if command_obj.owners_only && !framework_options.owners.contains(&ctx.author().id) { 165 | ctx.say("This command is only available to the bot owner!") 166 | .await?; 167 | return Ok(()); 168 | } 169 | 170 | if command_obj.subcommands.is_empty() { 171 | HelpCommandMode::Command(command_obj) 172 | } else { 173 | HelpCommandMode::Group(command_obj) 174 | } 175 | } 176 | }; 177 | 178 | let neutral_colour = ctx.neutral_colour().await; 179 | let embed = CreateEmbed::default() 180 | .title(match mode { 181 | HelpCommandMode::Root => format!("{} Help!", ctx.cache().current_user().name), 182 | HelpCommandMode::Group(c) | HelpCommandMode::Command(c) => { 183 | format!("{} Help!", c.qualified_name) 184 | } 185 | }) 186 | .description(match &mode { 187 | HelpCommandMode::Root => show_group_description(&get_command_mapping(commands)), 188 | HelpCommandMode::Command(command_obj) => { 189 | let mut msg = format!( 190 | "{}\n```/{}", 191 | command_obj 192 | .description 193 | .as_deref() 194 | .unwrap_or("Command description not found!"), 195 | command_obj.qualified_name 196 | ); 197 | 198 | format_params(&mut msg, command_obj); 199 | msg.push_str("```\n"); 200 | 201 | if !command_obj.parameters.is_empty() { 202 | msg.push_str("__**Parameter Descriptions**__\n"); 203 | command_obj.parameters.iter().for_each(|p| { 204 | let name = &p.name; 205 | let description = p.description.as_deref().unwrap_or("no description"); 206 | writeln!(msg, "`{name}`: {description}").unwrap(); 207 | }); 208 | } 209 | 210 | msg 211 | } 212 | HelpCommandMode::Group(group) => show_group_description(&{ 213 | let mut map = IndexMap::new(); 214 | map.insert( 215 | group.qualified_name.as_ref(), 216 | group.subcommands.iter().collect(), 217 | ); 218 | map 219 | }), 220 | }) 221 | .colour(neutral_colour) 222 | .author( 223 | serenity::CreateEmbedAuthor::new(ctx.author().name.as_str()) 224 | .icon_url(ctx.author().face()), 225 | ) 226 | .footer(serenity::CreateEmbedFooter::new(match mode { 227 | HelpCommandMode::Group(c) => Cow::Owned(format!( 228 | "Use `/help {} [command]` for more info on a command", 229 | c.qualified_name 230 | )), 231 | HelpCommandMode::Command(_) | HelpCommandMode::Root => { 232 | Cow::Borrowed("Use `/help [command]` for more info on a command") 233 | } 234 | })); 235 | 236 | ctx.send(poise::CreateReply::default().embed(embed)).await?; 237 | Ok(()) 238 | } 239 | 240 | // /set calls /help set 241 | pub use command_func as command; 242 | pub fn commands() -> [Command; 1] { 243 | [help()] 244 | } 245 | -------------------------------------------------------------------------------- /tts_migrations/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use sqlx::{Connection as _, Executor, Row}; 4 | 5 | use tts_core::{ 6 | constants::DB_SETUP_QUERY, 7 | opt_ext::OptionTryUnwrap, 8 | structs::{Config, PostgresConfig, Result, TTSMode}, 9 | }; 10 | 11 | type Transaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>; 12 | 13 | async fn migrate_single_to_modes( 14 | transaction: &mut Transaction<'_>, 15 | table: &str, 16 | new_table: &str, 17 | old_column: &str, 18 | id_column: &str, 19 | ) -> Result<()> { 20 | let insert_query_mode = 21 | format!("INSERT INTO {new_table}({id_column}, mode, voice) VALUES ($1, $2, $3)"); 22 | let insert_query_voice = format!( 23 | " 24 | INSERT INTO {table}({id_column}, voice_mode) VALUES ($1, $2) 25 | ON CONFLICT ({id_column}) DO UPDATE SET voice_mode = EXCLUDED.voice_mode 26 | " 27 | ); 28 | 29 | let mut delete_voice = false; 30 | for row in transaction 31 | .fetch_all(&*format!("SELECT * FROM {table}")) 32 | .await? 33 | { 34 | if let Ok(voice) = row.try_get::, _>(old_column) { 35 | delete_voice = true; 36 | if let Some(voice) = voice { 37 | let column_id: i64 = row.get(id_column); 38 | 39 | transaction 40 | .execute( 41 | sqlx::query(&insert_query_voice) 42 | .bind(column_id) 43 | .bind(TTSMode::gTTS), 44 | ) 45 | .await?; 46 | transaction 47 | .execute( 48 | sqlx::query(&insert_query_mode) 49 | .bind(column_id) 50 | .bind(TTSMode::gTTS) 51 | .bind(voice), 52 | ) 53 | .await?; 54 | } 55 | } else { 56 | break; 57 | } 58 | } 59 | 60 | if delete_voice { 61 | transaction 62 | .execute(&*format!("ALTER TABLE {table} DROP COLUMN {old_column}")) 63 | .await?; 64 | } 65 | 66 | Ok(()) 67 | } 68 | 69 | async fn migrate_speaking_rate_to_mode(transaction: &mut Transaction<'_>) -> Result<()> { 70 | let insert_query = " 71 | INSERT INTO user_voice(user_id, mode, speaking_rate) VALUES ($1, $2, $3) 72 | ON CONFLICT (user_id, mode) DO UPDATE SET speaking_rate = EXCLUDED.speaking_rate 73 | "; 74 | 75 | let mut delete_column = false; 76 | for row in transaction.fetch_all("SELECT * FROM userinfo").await? { 77 | if let Ok(speaking_rate) = row.try_get::("speaking_rate") { 78 | delete_column = true; 79 | 80 | if (speaking_rate - 1.0).abs() > f32::EPSILON { 81 | let user_id: i64 = row.get("user_id"); 82 | transaction 83 | .execute( 84 | sqlx::query(insert_query) 85 | .bind(user_id) 86 | .bind(TTSMode::gCloud) 87 | .bind(speaking_rate), 88 | ) 89 | .await?; 90 | } 91 | } else { 92 | break; 93 | } 94 | } 95 | 96 | if delete_column { 97 | transaction 98 | .execute("ALTER TABLE userinfo DROP COLUMN speaking_rate") 99 | .await?; 100 | } 101 | 102 | Ok(()) 103 | } 104 | 105 | // I'll use a proper framework for this one day 106 | async fn run(config: &mut toml::Table, pool: &sqlx::PgPool) -> Result<()> { 107 | let starting_conf = config.clone(); 108 | 109 | let stolen_config = std::mem::take(config); 110 | *config = pool 111 | .acquire() 112 | .await? 113 | .transaction(move |transaction| { 114 | Box::pin(async move { 115 | let mut config = stolen_config; 116 | run_(&mut config, transaction).await?; 117 | anyhow::Ok(config) 118 | }) 119 | }) 120 | .await?; 121 | 122 | if &starting_conf != config { 123 | let mut config_file = std::fs::File::create("config.toml")?; 124 | config_file.write_all(toml::to_string_pretty(&config)?.as_bytes())?; 125 | } 126 | 127 | Ok(()) 128 | } 129 | 130 | async fn run_(config: &mut toml::Table, transaction: &mut Transaction<'_>) -> Result<()> { 131 | let main_config = config["Main"].as_table_mut().try_unwrap()?; 132 | if main_config.get("setup").is_none() { 133 | transaction.execute(DB_SETUP_QUERY).await?; 134 | main_config.insert("setup".into(), true.into()); 135 | } 136 | 137 | if let Some(patreon_service) = main_config.remove("patreon_service") { 138 | let inner = toml::toml!("patreon_service" = patreon_service); 139 | config.insert("Premium-Info".into(), toml::Value::Table(inner)); 140 | } 141 | 142 | transaction.execute(" 143 | DO $$ BEGIN 144 | CREATE type TTSMode AS ENUM ( 145 | 'gtts', 146 | 'espeak', 147 | 'premium' 148 | ); 149 | 150 | ALTER TYPE TTSMode RENAME VALUE 'premium' TO 'gcloud'; 151 | ALTER TYPE TTSMode ADD VALUE 'polly'; 152 | EXCEPTION 153 | WHEN OTHERS THEN null; 154 | END $$; 155 | 156 | CREATE TABLE IF NOT EXISTS guild_voice ( 157 | guild_id bigint, 158 | mode TTSMode, 159 | voice text NOT NULL, 160 | 161 | PRIMARY KEY (guild_id, mode), 162 | 163 | FOREIGN KEY (guild_id) 164 | REFERENCES guilds (guild_id) 165 | ON DELETE CASCADE 166 | ); 167 | 168 | CREATE TABLE IF NOT EXISTS user_voice ( 169 | user_id bigint, 170 | mode TTSMode, 171 | voice text, 172 | 173 | PRIMARY KEY (user_id, mode), 174 | 175 | FOREIGN KEY (user_id) 176 | REFERENCES userinfo (user_id) 177 | ON DELETE CASCADE 178 | ); 179 | 180 | ALTER TABLE userinfo 181 | ADD COLUMN IF NOT EXISTS voice_mode TTSMode, 182 | ADD COLUMN IF NOT EXISTS premium_voice_mode TTSMode, 183 | ADD COLUMN IF NOT EXISTS bot_banned bool DEFAULT False, 184 | ADD COLUMN IF NOT EXISTS use_new_formatting bool DEFAULT False; 185 | ALTER TABLE guilds 186 | ADD COLUMN IF NOT EXISTS audience_ignore bool DEFAULT True, 187 | ADD COLUMN IF NOT EXISTS voice_mode TTSMode DEFAULT 'gtts', 188 | ADD COLUMN IF NOT EXISTS to_translate bool DEFAULT False, 189 | ADD COLUMN IF NOT EXISTS target_lang varchar(5), 190 | ADD COLUMN IF NOT EXISTS premium_user bigint, 191 | ADD COLUMN IF NOT EXISTS require_voice bool DEFAULT True, 192 | ADD COLUMN IF NOT EXISTS required_role bigint, 193 | ADD COLUMN IF NOT EXISTS required_prefix varchar(6), 194 | ADD COLUMN IF NOT EXISTS text_in_voice bool DEFAULT True, 195 | ADD COLUMN IF NOT EXISTS skip_emoji bool DEFAULT False; 196 | ALTER TABLE user_voice 197 | ADD COLUMN IF NOT EXISTS speaking_rate real; 198 | 199 | -- The old table had a pkey on traceback, now we hash and pkey on that 200 | ALTER TABLE errors 201 | ADD COLUMN IF NOT EXISTS traceback_hash bytea; 202 | DELETE FROM errors WHERE traceback_hash IS NULL; 203 | ALTER TABLE errors 204 | DROP CONSTRAINT IF EXISTS errors_pkey, 205 | DROP CONSTRAINT IF EXISTS traceback_hash_pkey, 206 | ADD CONSTRAINT traceback_hash_pkey PRIMARY KEY (traceback_hash); 207 | 208 | INSERT INTO user_voice (user_id, mode) VALUES(0, 'gtts') ON CONFLICT (user_id, mode) DO NOTHING; 209 | INSERT INTO guild_voice (guild_id, mode, voice) VALUES(0, 'gtts', 'en') ON CONFLICT (guild_id, mode) DO NOTHING; 210 | ").await?; 211 | 212 | migrate_single_to_modes(transaction, "userinfo", "user_voice", "voice", "user_id").await?; 213 | migrate_single_to_modes( 214 | transaction, 215 | "guilds", 216 | "guild_voice", 217 | "default_voice", 218 | "guild_id", 219 | ) 220 | .await?; 221 | migrate_speaking_rate_to_mode(transaction).await?; 222 | Ok(()) 223 | } 224 | 225 | pub async fn load_db_and_conf() -> Result<(sqlx::PgPool, Config)> { 226 | let mut config_toml: toml::Table = std::fs::read_to_string("config.toml")?.parse()?; 227 | let postgres: PostgresConfig = toml::Value::try_into(config_toml["PostgreSQL-Info"].clone())?; 228 | 229 | let pool_config = sqlx::postgres::PgPoolOptions::new(); 230 | let pool_config = if let Some(max_connections) = postgres.max_connections { 231 | pool_config.max_connections(max_connections) 232 | } else { 233 | pool_config 234 | }; 235 | 236 | let pool_options = sqlx::postgres::PgConnectOptions::new() 237 | .host(&postgres.host) 238 | .username(&postgres.user) 239 | .database(&postgres.database) 240 | .password(&postgres.password); 241 | 242 | let pool = pool_config.connect_with(pool_options).await?; 243 | run(&mut config_toml, &pool).await?; 244 | 245 | let config = config_toml.try_into()?; 246 | Ok((pool, config)) 247 | } 248 | -------------------------------------------------------------------------------- /tts_commands/src/premium.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write as _; 2 | 3 | use aformat::{ArrayString, aformat, aformat_into}; 4 | 5 | use poise::{ 6 | CreateReply, 7 | futures_util::{StreamExt as _, stream::BoxStream}, 8 | serenity_prelude::{self as serenity, builder::*}, 9 | }; 10 | 11 | use tts_core::{ 12 | common::remove_premium, 13 | constants::PREMIUM_NEUTRAL_COLOUR, 14 | opt_ext::OptionTryUnwrap as _, 15 | structs::{Command, CommandResult, Context, Result, TTSMode}, 16 | traits::PoiseContextExt, 17 | }; 18 | 19 | #[derive(sqlx::FromRow)] 20 | struct GuildIdRow { 21 | guild_id: i64, 22 | } 23 | 24 | fn get_premium_guilds<'a>( 25 | conn: impl sqlx::PgExecutor<'a> + 'a, 26 | premium_user: serenity::UserId, 27 | ) -> BoxStream<'a, Result> { 28 | sqlx::query_as("SELECT guild_id FROM guilds WHERE premium_user = $1") 29 | .bind(premium_user.get() as i64) 30 | .fetch(conn) 31 | } 32 | 33 | async fn get_premium_guild_count<'a>( 34 | conn: impl sqlx::PgExecutor<'a> + 'a, 35 | premium_user: serenity::UserId, 36 | ) -> Result { 37 | let guilds = get_premium_guilds(conn, premium_user); 38 | Ok(guilds.count().await as i64) 39 | } 40 | 41 | /// Shows how you can help support TTS Bot's development and hosting! 42 | #[poise::command( 43 | category = "Premium", 44 | prefix_command, 45 | slash_command, 46 | required_bot_permissions = "SEND_MESSAGES", 47 | aliases("purchase", "donate") 48 | )] 49 | pub async fn premium(ctx: Context<'_>) -> CommandResult { 50 | let mut out = ArrayString::<377>::new(); 51 | let msg = if let Some(premium_config) = &ctx.data().premium_config { 52 | let patreon_url = premium_config.patreon_page_url; 53 | if premium_config.discord_monetisation_enabled == Some(true) { 54 | let application_id = ctx.http().application_id().try_unwrap()?; 55 | aformat_into!( 56 | out, 57 | concat!( 58 | "To support the development and hosting of TTS Bot and get access to TTS Bot Premium, ", 59 | "including more modes (`/set mode`), many more voices (`/set voice`), ", 60 | "and extra options such as TTS translation, follow one of these links:\n", 61 | "Patreon: <{patreon_url}>\nDiscord: https://discord.com/application-directory/{application_id}/store" 62 | ) 63 | ); 64 | } else { 65 | aformat_into!( 66 | out, 67 | concat!( 68 | "To support the development and hosting of TTS Bot and get access to TTS Bot Premium, ", 69 | "including more modes (`/set mode`), many more voices (`/set voice`), ", 70 | "and extra options such as TTS translation, subscribe on Patreon\n!\n", 71 | "<{patreon_url}>" 72 | ) 73 | ); 74 | } 75 | 76 | &out 77 | } else { 78 | "This version of TTS Bot does not have premium features enabled." 79 | }; 80 | 81 | ctx.say(msg).await?; 82 | Ok(()) 83 | } 84 | 85 | /// Activates a server for TTS Bot Premium! 86 | #[poise::command( 87 | category = "Premium", 88 | guild_only, 89 | prefix_command, 90 | slash_command, 91 | aliases("activate"), 92 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 93 | )] 94 | pub async fn premium_activate(ctx: Context<'_>) -> CommandResult { 95 | let guild_id = ctx.guild_id().unwrap(); 96 | let data = ctx.data(); 97 | 98 | if data.is_premium_simple(ctx.http(), guild_id).await? { 99 | ctx.say("Hey, this server is already premium!").await?; 100 | return Ok(()); 101 | } 102 | 103 | let author = ctx.author(); 104 | let linked_guilds = get_premium_guild_count(&data.pool, author.id).await?; 105 | let error_msg = match data.fetch_premium_info(ctx.http(), author.id).await? { 106 | Some(tier) => { 107 | if linked_guilds >= tier.entitled_servers.get().into() { 108 | Some(Cow::Owned(format!( 109 | "Hey, you already have {linked_guilds} servers linked, you are only subscribed to the {} tier!", 110 | tier.entitled_servers 111 | ))) 112 | } else { 113 | None 114 | } 115 | } 116 | None => Some(Cow::Borrowed( 117 | "Hey, I don't think you are subscribed to TTS Bot Premium!", 118 | )), 119 | }; 120 | 121 | if let Some(error_msg) = error_msg { 122 | let embed = CreateEmbed::default() 123 | .title("TTS Bot Premium") 124 | .description(error_msg) 125 | .thumbnail(data.premium_avatar_url.as_str()) 126 | .colour(PREMIUM_NEUTRAL_COLOUR) 127 | .footer(CreateEmbedFooter::new(concat!( 128 | "If you have just subscribed to TTS Bot Premium, please wait up to an hour and try again!\n", 129 | "For support, please join the support server via `/invite`." 130 | ))); 131 | 132 | ctx.send(CreateReply::default().embed(embed)).await?; 133 | return Ok(()); 134 | } 135 | 136 | let author_id = author.id.get() as i64; 137 | data.userinfo_db.create_row(author_id).await?; 138 | data.guilds_db 139 | .set_one(guild_id.into(), "premium_user", &author_id) 140 | .await?; 141 | data.guilds_db 142 | .set_one(guild_id.into(), "voice_mode", &TTSMode::gCloud) 143 | .await?; 144 | 145 | ctx.say("Done! This server is now premium!").await?; 146 | 147 | let guild = ctx.cache().guild(guild_id); 148 | let guild_name = match guild.as_ref() { 149 | Some(g) => g.name.as_str(), 150 | None => "", 151 | }; 152 | 153 | tracing::info!( 154 | "{} | {} linked premium to {} | {}, they had {} linked servers", 155 | author.tag(), 156 | author.id, 157 | guild_name, 158 | guild_id, 159 | linked_guilds 160 | ); 161 | Ok(()) 162 | } 163 | 164 | /// Lists all servers you activated for TTS Bot Premium 165 | #[poise::command( 166 | category = "Premium", 167 | prefix_command, 168 | slash_command, 169 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 170 | )] 171 | pub async fn list_premium(ctx: Context<'_>) -> CommandResult { 172 | let data = ctx.data(); 173 | let Some(premium_info) = data.fetch_premium_info(ctx.http(), ctx.author().id).await? else { 174 | ctx.say("I cannot confirm you are subscribed to premium, so you don't have any premium servers!").await?; 175 | return Ok(()); 176 | }; 177 | 178 | let mut premium_guilds = 0; 179 | let mut embed_desc = Cow::Borrowed(""); 180 | let mut guilds = get_premium_guilds(&data.pool, ctx.author().id); 181 | while let Some(guild_row) = guilds.next().await { 182 | premium_guilds += 1; 183 | let guild_id = serenity::GuildId::new(guild_row?.guild_id as u64); 184 | if let Some(guild_ref) = ctx.cache().guild(guild_id) { 185 | writeln!(embed_desc.to_mut(), "- (`{guild_id}`) {}", guild_ref.name)?; 186 | } else { 187 | writeln!(embed_desc.to_mut(), "- (`{guild_id}`) ****")?; 188 | } 189 | } 190 | 191 | let author = ctx.author(); 192 | let remaining_guilds = premium_info.entitled_servers.get() - premium_guilds; 193 | if embed_desc.is_empty() { 194 | embed_desc = Cow::Borrowed("None... set some servers with `/premium_activate`!"); 195 | } 196 | 197 | let footer = aformat!("You have {remaining_guilds} server(s) remaining for premium activation"); 198 | let embed = CreateEmbed::new() 199 | .title("The premium servers you have activated:") 200 | .description(embed_desc) 201 | .colour(PREMIUM_NEUTRAL_COLOUR) 202 | .author(CreateEmbedAuthor::new(&*author.name).icon_url(author.face())) 203 | .footer(CreateEmbedFooter::new(footer.as_str())); 204 | 205 | ctx.send(CreateReply::default().embed(embed)).await?; 206 | Ok(()) 207 | } 208 | 209 | /// Deactivates a server from TTS Bot Premium. 210 | #[poise::command( 211 | category = "Premium", 212 | prefix_command, 213 | slash_command, 214 | guild_only, 215 | aliases("premium_remove", "premium_delete"), 216 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 217 | )] 218 | pub async fn premium_deactivate(ctx: Context<'_>) -> CommandResult { 219 | let data = ctx.data(); 220 | let author = ctx.author(); 221 | let guild_id = ctx.guild_id().unwrap(); 222 | let guild_row = data.guilds_db.get(guild_id.get() as i64).await?; 223 | 224 | let Some(premium_user) = guild_row.premium_user else { 225 | let msg = "This server isn't activated for premium, so I can't deactivate it!"; 226 | ctx.send_ephemeral(msg).await?; 227 | return Ok(()); 228 | }; 229 | 230 | if premium_user != author.id { 231 | let msg = "You are not setup as the premium user for this server, so cannot deactivate it!"; 232 | ctx.send_ephemeral(msg).await?; 233 | return Ok(()); 234 | } 235 | 236 | remove_premium(&data, guild_id).await?; 237 | 238 | let msg = "Deactivated premium from this server."; 239 | ctx.say(msg).await?; 240 | Ok(()) 241 | } 242 | 243 | pub fn commands() -> [Command; 4] { 244 | [ 245 | premium(), 246 | premium_activate(), 247 | list_premium(), 248 | premium_deactivate(), 249 | ] 250 | } 251 | -------------------------------------------------------------------------------- /tts_events/src/message.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use aformat::aformat; 4 | use tracing::info; 5 | 6 | use self::serenity::{ 7 | CreateEmbed, CreateEmbedFooter, CreateMessage, ExecuteWebhook, GenericGuildChannelRef, 8 | Mentionable, 9 | }; 10 | use poise::serenity_prelude as serenity; 11 | 12 | use tts_core::{ 13 | common::{dm_generic, random_footer}, 14 | constants::DM_WELCOME_MESSAGE, 15 | opt_ext::OptionTryUnwrap, 16 | structs::{Data, Result}, 17 | }; 18 | 19 | use tts::process_tts_msg; 20 | 21 | mod tts; 22 | 23 | pub async fn handle(ctx: &serenity::Context, new_message: &serenity::Message) -> Result<()> { 24 | tokio::try_join!(process_tts_msg(ctx, new_message), async { 25 | process_support_dm(ctx, new_message).await?; 26 | process_mention_msg(ctx, new_message).await?; 27 | Ok(()) 28 | })?; 29 | 30 | Ok(()) 31 | } 32 | 33 | async fn process_mention_msg(ctx: &serenity::Context, message: &serenity::Message) -> Result<()> { 34 | let data = ctx.data_ref::(); 35 | let Some(bot_mention_regex) = data.regex_cache.bot_mention.get() else { 36 | return Ok(()); 37 | }; 38 | 39 | if !bot_mention_regex.is_match(&message.content) { 40 | return Ok(()); 41 | } 42 | 43 | let Some(guild_id) = message.guild_id else { 44 | return Ok(()); 45 | }; 46 | 47 | let bot_user = ctx.cache.current_user().id; 48 | let bot_send_messages = { 49 | let Some(guild) = ctx.cache.guild(guild_id) else { 50 | return Ok(()); 51 | }; 52 | 53 | let bot_member = guild.members.get(&bot_user).try_unwrap()?; 54 | match guild.channel(message.channel_id) { 55 | Some(GenericGuildChannelRef::Channel(ch)) => { 56 | guild.user_permissions_in(ch, bot_member).send_messages() 57 | } 58 | Some(GenericGuildChannelRef::Thread(th)) => { 59 | let parent_channel = guild.channels.get(&th.parent_id).try_unwrap()?; 60 | guild 61 | .user_permissions_in(parent_channel, bot_member) 62 | .send_messages_in_threads() 63 | } 64 | None => return Ok(()), 65 | } 66 | }; 67 | 68 | let guild_row = data.guilds_db.get(guild_id.into()).await?; 69 | let mut prefix = guild_row.prefix.as_str().replace(['`', '\\'], ""); 70 | 71 | if bot_send_messages { 72 | prefix.insert_str(0, "Current prefix for this server is: "); 73 | message.channel_id.say(&ctx.http, prefix).await?; 74 | } else { 75 | let msg = { 76 | let guild = ctx.cache.guild(guild_id); 77 | let guild_name = match guild.as_ref() { 78 | Some(g) => &g.name, 79 | None => "Unknown Server", 80 | }; 81 | 82 | format!( 83 | "My prefix for `{guild_name}` is {prefix} however I do not have permission to send messages so I cannot respond to your commands!" 84 | ) 85 | }; 86 | 87 | let msg = CreateMessage::default().content(msg); 88 | match message.author.id.dm(&ctx.http, msg).await { 89 | Err(serenity::Error::Http(error)) 90 | if error.status_code() == Some(serenity::StatusCode::FORBIDDEN) => {} 91 | Err(error) => return Err(anyhow::Error::from(error)), 92 | _ => {} 93 | } 94 | } 95 | 96 | Ok(()) 97 | } 98 | 99 | async fn process_support_dm(ctx: &serenity::Context, message: &serenity::Message) -> Result<()> { 100 | let data = ctx.data_ref::(); 101 | let channel_id = message.channel_id; 102 | if message.guild_id.is_some() { 103 | return process_support_response(ctx, message, data, channel_id).await; 104 | } 105 | 106 | if message.author.bot() || message.content.starts_with('-') { 107 | return Ok(()); 108 | } 109 | 110 | data.analytics.log(Cow::Borrowed("dm"), false); 111 | 112 | let userinfo = data.userinfo_db.get(message.author.id.into()).await?; 113 | if userinfo.dm_welcomed() { 114 | let content = message.content.to_lowercase(); 115 | 116 | if content.contains("discord.gg") { 117 | let content = { 118 | let current_user = ctx.cache.current_user(); 119 | format!( 120 | "Join {} and look in {} to invite <@{}>!", 121 | data.config.main_server_invite, 122 | data.config.invite_channel.mention(), 123 | current_user.id 124 | ) 125 | }; 126 | 127 | channel_id.say(&ctx.http, content).await?; 128 | } else if content.as_str() == "help" { 129 | channel_id.say(&ctx.http, "We cannot help you unless you ask a question, if you want the help command just do `-help`!").await?; 130 | } else if !userinfo.dm_blocked() { 131 | let webhook_username = { 132 | let mut tag = message.author.tag().into_owned(); 133 | tag.push_str(&aformat!(" ({})", message.author.id)); 134 | tag 135 | }; 136 | 137 | let mut attachments = Vec::new(); 138 | for attachment in &message.attachments { 139 | let attachment_builder = serenity::CreateAttachment::url( 140 | &ctx.http, 141 | attachment.url.as_str(), 142 | attachment.filename.to_string(), 143 | ) 144 | .await?; 145 | attachments.push(attachment_builder); 146 | } 147 | 148 | let builder = ExecuteWebhook::default() 149 | .files(attachments) 150 | .content(message.content.as_str()) 151 | .username(webhook_username) 152 | .avatar_url(message.author.face()) 153 | .allowed_mentions(serenity::CreateAllowedMentions::new()) 154 | .embeds( 155 | message 156 | .embeds 157 | .iter() 158 | .cloned() 159 | .map(Into::into) 160 | .collect::>(), 161 | ); 162 | 163 | data.webhooks 164 | .dm_logs 165 | .execute(&ctx.http, false, builder) 166 | .await?; 167 | } 168 | } else { 169 | let (client_id, title) = { 170 | let current_user = ctx.cache.current_user(); 171 | ( 172 | current_user.id, 173 | aformat!("Welcome to {} Support DMs!", ¤t_user.name), 174 | ) 175 | }; 176 | 177 | let embeds = [CreateEmbed::default() 178 | .title(title.as_str()) 179 | .description(DM_WELCOME_MESSAGE) 180 | .footer(CreateEmbedFooter::new(random_footer( 181 | &data.config.main_server_invite, 182 | client_id, 183 | )))]; 184 | 185 | let welcome_msg = channel_id 186 | .send_message(&ctx.http, CreateMessage::default().embeds(&embeds)) 187 | .await?; 188 | 189 | data.userinfo_db 190 | .set_one(message.author.id.into(), "dm_welcomed", &true) 191 | .await?; 192 | if channel_id.pins(&ctx.http).await?.len() < 50 { 193 | welcome_msg.pin(&ctx.http, None).await?; 194 | } 195 | 196 | info!( 197 | "{} just got the 'Welcome to support DMs' message", 198 | message.author.tag(), 199 | ); 200 | } 201 | 202 | Ok(()) 203 | } 204 | 205 | async fn process_support_response( 206 | ctx: &serenity::Context, 207 | message: &serenity::Message, 208 | data: &Data, 209 | channel_id: serenity::GenericChannelId, 210 | ) -> Result<()> { 211 | if data.webhooks.dm_logs.channel_id.try_unwrap()? != channel_id.expect_channel() { 212 | return Ok(()); 213 | } 214 | 215 | let Some(reference) = &message.message_reference else { 216 | return Ok(()); 217 | }; 218 | 219 | let Some(resolved_id) = reference.message_id else { 220 | return Ok(()); 221 | }; 222 | 223 | let (resolved_author_name, resolved_author_discrim) = { 224 | let message = channel_id.message(ctx, resolved_id).await?; 225 | (message.author.name, message.author.discriminator) 226 | }; 227 | 228 | if resolved_author_discrim.is_some() { 229 | return Ok(()); 230 | } 231 | 232 | let (target, target_tag) = { 233 | let Some(re_match) = data 234 | .regex_cache 235 | .id_in_brackets 236 | .captures(&resolved_author_name) 237 | else { 238 | return Ok(()); 239 | }; 240 | 241 | let Some(target_id_match) = re_match.get(1) else { 242 | return Ok(()); 243 | }; 244 | 245 | let target_id = target_id_match.as_str().parse::()?; 246 | let target_tag = target_id.to_user(ctx).await?.tag().into_owned(); 247 | 248 | (target_id, target_tag) 249 | }; 250 | 251 | let attachment_url = message.attachments.first().map(|a| a.url.as_str()); 252 | 253 | let (content, embed) = dm_generic( 254 | ctx, 255 | &message.author, 256 | target, 257 | target_tag, 258 | attachment_url, 259 | &message.content, 260 | ) 261 | .await?; 262 | 263 | let embeds = [CreateEmbed::from(embed)]; 264 | channel_id 265 | .send_message( 266 | &ctx.http, 267 | CreateMessage::default().content(content).embeds(&embeds), 268 | ) 269 | .await?; 270 | 271 | Ok(()) 272 | } 273 | -------------------------------------------------------------------------------- /tts_events/src/message/tts.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use aformat::ToArrayString as _; 4 | use poise::serenity_prelude as serenity; 5 | 6 | use tts_core::{ 7 | common::{clean_msg, fetch_audio, prepare_url}, 8 | database::{GuildRow, UserRow}, 9 | errors, 10 | opt_ext::OptionTryUnwrap as _, 11 | structs::{Data, IsPremium, JoinVCToken, Result, TTSMode}, 12 | traits::SongbirdManagerExt as _, 13 | }; 14 | 15 | pub(crate) async fn process_tts_msg( 16 | ctx: &serenity::Context, 17 | message: &serenity::Message, 18 | ) -> Result<()> { 19 | let data = ctx.data_ref::(); 20 | let Some(guild_id) = message.guild_id else { 21 | return Ok(()); 22 | }; 23 | 24 | let (guild_row, user_row) = tokio::try_join!( 25 | data.guilds_db.get(guild_id.into()), 26 | data.userinfo_db.get(message.author.id.into()), 27 | )?; 28 | 29 | let Some((mut content, to_autojoin)) = run_checks(ctx, message, &guild_row, *user_row)? else { 30 | return Ok(()); 31 | }; 32 | 33 | let is_premium = data.is_premium_simple(&ctx.http, guild_id).await?; 34 | let (voice, mode) = { 35 | if let Some(channel_id) = to_autojoin { 36 | let join_vc_lock = JoinVCToken::acquire(data, guild_id); 37 | match data.songbird.join_vc(join_vc_lock, channel_id).await { 38 | Ok(call) => call, 39 | Err(songbird::error::JoinError::TimedOut) => return Ok(()), 40 | Err(err) => return Err(err.into()), 41 | }; 42 | } 43 | 44 | let is_ephemeral = message 45 | .flags 46 | .is_some_and(|f| f.contains(serenity::model::channel::MessageFlags::EPHEMERAL)); 47 | 48 | let m; 49 | let member_nick = match &message.member { 50 | Some(member) => member.nick.as_deref(), 51 | None if message.webhook_id.is_none() && !is_ephemeral => { 52 | m = guild_id.member(ctx, message.author.id).await?; 53 | m.nick.as_deref() 54 | } 55 | None => None, 56 | }; 57 | 58 | let (voice, mode) = data 59 | .parse_user_or_guild_with_premium(message.author.id, Some((guild_id, is_premium))) 60 | .await?; 61 | 62 | let nickname_row = data 63 | .nickname_db 64 | .get([guild_id.into(), message.author.id.into()]) 65 | .await?; 66 | 67 | content = clean_msg( 68 | &content, 69 | &message.author, 70 | &ctx.cache, 71 | guild_id, 72 | member_nick, 73 | &message.attachments, 74 | &voice, 75 | guild_row.xsaid(), 76 | guild_row.skip_emoji(), 77 | guild_row.repeated_chars, 78 | nickname_row.name.as_deref(), 79 | user_row.use_new_formatting(), 80 | &data.regex_cache, 81 | &data.last_to_xsaid_tracker, 82 | ); 83 | 84 | (voice, mode) 85 | }; 86 | 87 | // Final check, make sure we aren't sending an empty message or just symbols. 88 | let mut removed_chars_content = content.clone(); 89 | removed_chars_content.retain(|c| !" ?.)'!\":".contains(c)); 90 | if removed_chars_content.is_empty() { 91 | return Ok(()); 92 | } 93 | 94 | let speaking_rate = data.speaking_rate(message.author.id, mode).await?; 95 | let url = prepare_url( 96 | data.config.tts_service.clone(), 97 | &content, 98 | &voice, 99 | mode, 100 | &speaking_rate, 101 | &guild_row.msg_length.to_arraystring(), 102 | guild_row.target_lang(IsPremium::from(is_premium)), 103 | ); 104 | 105 | let call_lock = if let Some(call) = data.songbird.get(guild_id) { 106 | call 107 | } else { 108 | // At this point, the bot is "in" the voice channel, but without a voice client, 109 | // this is usually if the bot restarted but the bot is still in the vc from the last boot. 110 | let voice_channel_id = { 111 | let guild = ctx.cache.guild(guild_id).try_unwrap()?; 112 | guild 113 | .voice_states 114 | .get(&message.author.id) 115 | .and_then(|vs| vs.channel_id) 116 | .try_unwrap()? 117 | }; 118 | 119 | let join_vc_token = JoinVCToken::acquire(data, guild_id); 120 | match data.songbird.join_vc(join_vc_token, voice_channel_id).await { 121 | Ok(call) => call, 122 | Err(songbird::error::JoinError::TimedOut) => return Ok(()), 123 | Err(err) => return Err(err.into()), 124 | } 125 | }; 126 | 127 | // Pre-fetch the audio to handle max_length errors 128 | let tts_auth_key = data.config.tts_service_auth_key.as_deref(); 129 | let Some(audio) = fetch_audio(&data.reqwest, url.clone(), tts_auth_key).await? else { 130 | return Ok(()); 131 | }; 132 | 133 | let hint = audio 134 | .headers() 135 | .get(reqwest::header::CONTENT_TYPE) 136 | .map(|ct| { 137 | let mut hint = songbird::input::core::probe::Hint::new(); 138 | hint.mime_type(ct.to_str()?); 139 | Ok::<_, anyhow::Error>(hint) 140 | }) 141 | .transpose()?; 142 | 143 | let input = Box::new(std::io::Cursor::new(audio.bytes().await?)); 144 | let wrapped_audio = 145 | songbird::input::LiveInput::Raw(songbird::input::AudioStream { input, hint }); 146 | 147 | let track_handle = { 148 | let mut call = call_lock.lock().await; 149 | call.enqueue_input(songbird::input::Input::Live(wrapped_audio, None)) 150 | .await 151 | }; 152 | 153 | data.analytics.log( 154 | Cow::Borrowed(match mode { 155 | TTSMode::gTTS => "gTTS_tts", 156 | TTSMode::eSpeak => "eSpeak_tts", 157 | TTSMode::gCloud => "gCloud_tts", 158 | TTSMode::Polly => "Polly_tts", 159 | }), 160 | false, 161 | ); 162 | 163 | let guild_name = ctx.cache.guild(guild_id).try_unwrap()?.name.to_string(); 164 | let (blank_name, blank_value, blank_inline) = errors::blank_field(); 165 | 166 | let extra_fields = [ 167 | ("Guild Name", Cow::Owned(guild_name), true), 168 | ("Guild ID", Cow::Owned(guild_id.to_string()), true), 169 | (blank_name, blank_value, blank_inline), 170 | ( 171 | "Message length", 172 | Cow::Owned(content.len().to_string()), 173 | true, 174 | ), 175 | ("Voice", voice, true), 176 | ("Mode", Cow::Owned(mode.to_string()), true), 177 | ]; 178 | 179 | let author_name = message.author.name.clone(); 180 | let icon_url = message.author.face(); 181 | 182 | errors::handle_track( 183 | ctx.clone(), 184 | extra_fields, 185 | author_name, 186 | icon_url, 187 | &track_handle, 188 | ) 189 | .map_err(Into::into) 190 | } 191 | 192 | fn run_checks( 193 | ctx: &serenity::Context, 194 | message: &serenity::Message, 195 | guild_row: &GuildRow, 196 | user_row: UserRow, 197 | ) -> Result)>> { 198 | if user_row.bot_banned() { 199 | return Ok(None); 200 | } 201 | 202 | let Some(guild) = message.guild(&ctx.cache) else { 203 | return Ok(None); 204 | }; 205 | 206 | // `expect_channel` is fine as we are checking if the message is in the setup channel, or a voice channel. 207 | let channel_id = message.channel_id.expect_channel(); 208 | if guild_row.channel != Some(channel_id) { 209 | // "Text in Voice" works by just sending messages in voice channels, so checking for it just takes 210 | // checking if the message's channel_id is the author's voice channel_id 211 | if !guild_row.text_in_voice() { 212 | return Ok(None); 213 | } 214 | 215 | let author_vc = guild 216 | .voice_states 217 | .get(&message.author.id) 218 | .and_then(|c| c.channel_id); 219 | 220 | if author_vc.is_none_or(|author_vc| author_vc != channel_id) { 221 | return Ok(None); 222 | } 223 | } 224 | 225 | if let Some(required_role) = guild_row.required_role 226 | && let Some(message_member) = &message.member 227 | && !message_member.roles.contains(&required_role) 228 | { 229 | let Some(channel) = guild.channels.get(&channel_id) else { 230 | return Ok(None); 231 | }; 232 | 233 | if !guild 234 | .partial_member_permissions_in(channel, message.author.id, message_member) 235 | .administrator() 236 | { 237 | return Ok(None); 238 | } 239 | } 240 | 241 | let mut content = serenity::content_safe( 242 | &guild, 243 | &message.content, 244 | serenity::ContentSafeOptions::default() 245 | .clean_here(false) 246 | .clean_everyone(false), 247 | &message.mentions, 248 | ); 249 | 250 | if content.len() >= 1500 { 251 | return Ok(None); 252 | } 253 | 254 | content = content.to_lowercase(); 255 | 256 | if let Some(required_prefix) = &guild_row.required_prefix { 257 | if let Some(stripped_content) = content.strip_prefix(required_prefix.as_str()) { 258 | content = String::from(stripped_content); 259 | } else { 260 | return Ok(None); 261 | } 262 | } 263 | 264 | if content.starts_with(guild_row.prefix.as_str()) { 265 | return Ok(None); 266 | } 267 | 268 | let voice_state = guild.voice_states.get(&message.author.id); 269 | let bot_voice_state = guild.voice_states.get(&ctx.cache.current_user().id); 270 | 271 | let mut to_autojoin = None; 272 | if message.author.bot() { 273 | if guild_row.bot_ignore() || bot_voice_state.is_none() { 274 | return Ok(None); // Is bot 275 | } 276 | } else { 277 | // If the bot is in vc 278 | if let Some(vc) = bot_voice_state { 279 | // If the user needs to be in the vc, and the user's voice channel is not the same as the bot's 280 | if guild_row.require_voice() 281 | && vc.channel_id != voice_state.and_then(|vs| vs.channel_id) 282 | { 283 | return Ok(None); // Wrong vc 284 | } 285 | // Else if the user is in the vc and autojoin is on 286 | } else if let Some(voice_state) = voice_state 287 | && guild_row.auto_join() 288 | { 289 | to_autojoin = Some(voice_state.channel_id.try_unwrap()?); 290 | } else { 291 | return Ok(None); // Bot not in vc 292 | } 293 | 294 | if guild_row.require_voice() { 295 | let voice_channel = voice_state.unwrap().channel_id.try_unwrap()?; 296 | let channel = guild.channels.get(&voice_channel).try_unwrap()?; 297 | 298 | if channel.base.kind == serenity::ChannelType::Stage 299 | && voice_state.is_some_and(serenity::VoiceState::suppress) 300 | && guild_row.audience_ignore() 301 | { 302 | return Ok(None); // Is audience 303 | } 304 | } 305 | } 306 | 307 | Ok(Some((content, to_autojoin))) 308 | } 309 | -------------------------------------------------------------------------------- /tts_commands/src/other.rs: -------------------------------------------------------------------------------- 1 | use aformat::{aformat, astr}; 2 | use anyhow::Error; 3 | use num_format::{Locale, ToFormattedString}; 4 | 5 | use poise::{ 6 | CreateReply, 7 | serenity_prelude::{ 8 | self as serenity, Mentionable as _, builder::*, small_fixed_array::FixedString, 9 | }, 10 | }; 11 | 12 | use aformat::ToArrayString; 13 | use tts_core::{ 14 | common::{fetch_audio, prepare_url}, 15 | constants::OPTION_SEPERATORS, 16 | opt_ext::OptionTryUnwrap, 17 | require_guild, 18 | structs::{ApplicationContext, Command, CommandResult, Context, IsPremium, TTSMode}, 19 | traits::PoiseContextExt as _, 20 | }; 21 | 22 | /// Shows how long TTS Bot has been online 23 | #[poise::command( 24 | category = "Extra Commands", 25 | prefix_command, 26 | slash_command, 27 | required_bot_permissions = "SEND_MESSAGES" 28 | )] 29 | pub async fn uptime(ctx: Context<'_>) -> CommandResult { 30 | let start_time = ctx.data().start_time; 31 | let time_since_start = start_time.duration_since(std::time::UNIX_EPOCH)?.as_secs(); 32 | let msg = { 33 | let current_user = ctx.cache().current_user().mention(); 34 | aformat!("{current_user} has been up since: ") 35 | }; 36 | 37 | ctx.say(&*msg).await?; 38 | Ok(()) 39 | } 40 | 41 | /// Generates TTS and sends it in the current text channel! 42 | #[poise::command( 43 | category = "Extra Commands", 44 | prefix_command, 45 | slash_command, 46 | required_bot_permissions = "SEND_MESSAGES | ATTACH_FILES" 47 | )] 48 | pub async fn tts( 49 | ctx: Context<'_>, 50 | #[description = "The text to TTS"] 51 | #[rest] 52 | message: FixedString, 53 | ) -> CommandResult { 54 | let is_unnecessary_command_invoke = async { 55 | if !matches!(ctx, poise::Context::Prefix(_)) { 56 | return Ok(false); 57 | } 58 | 59 | let (guild_id, author_voice_cid, bot_voice_cid) = { 60 | if let Some(guild) = ctx.guild() { 61 | ( 62 | guild.id, 63 | guild 64 | .voice_states 65 | .get(&ctx.author().id) 66 | .and_then(|vc| vc.channel_id), 67 | guild 68 | .voice_states 69 | .get(&ctx.cache().current_user().id) 70 | .and_then(|vc| vc.channel_id), 71 | ) 72 | } else { 73 | return Ok(false); 74 | } 75 | }; 76 | 77 | if author_voice_cid.is_some() && author_voice_cid == bot_voice_cid { 78 | let setup_channel = ctx.data().guilds_db.get(guild_id.into()).await?.channel; 79 | if setup_channel == Some(ctx.channel_id().expect_channel()) { 80 | return Ok(true); 81 | } 82 | } 83 | 84 | Ok::<_, Error>(false) 85 | }; 86 | 87 | if is_unnecessary_command_invoke.await? { 88 | ctx.say("You don't need to include the `/tts` for messages to be said!") 89 | .await?; 90 | Ok(()) 91 | } else { 92 | tts_(ctx, ctx.author(), &message).await 93 | } 94 | } 95 | 96 | async fn tts_(ctx: Context<'_>, author: &serenity::User, message: &str) -> CommandResult { 97 | let attachment = { 98 | let data = ctx.data(); 99 | let http = ctx.http(); 100 | let guild_info = if let Some(guild_id) = ctx.guild_id() { 101 | Some((guild_id, data.is_premium_simple(http, guild_id).await?)) 102 | } else { 103 | None 104 | }; 105 | 106 | let (voice, mode) = data 107 | .parse_user_or_guild_with_premium(author.id, guild_info) 108 | .await?; 109 | 110 | let guild_row; 111 | let translation_lang = if let Some((guild_id, is_premium)) = guild_info { 112 | guild_row = data.guilds_db.get(guild_id.into()).await?; 113 | guild_row.target_lang(IsPremium::from(is_premium)) 114 | } else { 115 | None 116 | }; 117 | 118 | let author_name: String = author 119 | .name 120 | .chars() 121 | .filter(|char| char.is_alphanumeric()) 122 | .collect(); 123 | let speaking_rate = data.speaking_rate(author.id, mode).await?; 124 | 125 | let url = prepare_url( 126 | data.config.tts_service.clone(), 127 | message, 128 | &voice, 129 | mode, 130 | &speaking_rate, 131 | &u64::MAX.to_arraystring(), 132 | translation_lang, 133 | ); 134 | 135 | let auth_key = data.config.tts_service_auth_key.as_deref(); 136 | let audio = fetch_audio(&data.reqwest, url, auth_key) 137 | .await? 138 | .try_unwrap()? 139 | .bytes() 140 | .await?; 141 | 142 | let mut file_name = author_name; 143 | file_name.push_str(&aformat!( 144 | "-{}.{}", 145 | ctx.id(), 146 | match mode { 147 | TTSMode::gTTS | TTSMode::gCloud | TTSMode::Polly => astr!("mp3"), 148 | TTSMode::eSpeak => astr!("wav"), 149 | } 150 | )); 151 | 152 | serenity::CreateAttachment::bytes(audio.to_vec(), file_name) 153 | }; 154 | 155 | ctx.send( 156 | CreateReply::default() 157 | .content("Generated some TTS!") 158 | .attachment(attachment), 159 | ) 160 | .await?; 161 | 162 | Ok(()) 163 | } 164 | 165 | #[poise::command( 166 | category = "Extra Commands", 167 | hide_in_help, 168 | context_menu_command = "Speak with their voice!" 169 | )] 170 | pub async fn tts_speak_as( 171 | ctx: ApplicationContext<'_>, 172 | message: serenity::Message, 173 | ) -> CommandResult { 174 | tts_(ctx.into(), &message.author, &message.content).await 175 | } 176 | 177 | #[poise::command( 178 | category = "Extra Commands", 179 | hide_in_help, 180 | context_menu_command = "Speak with your voice!" 181 | )] 182 | pub async fn tts_speak(ctx: ApplicationContext<'_>, message: serenity::Message) -> CommandResult { 183 | tts_(ctx.into(), &ctx.interaction.user, &message.content).await 184 | } 185 | 186 | /// Shows various different stats 187 | #[poise::command( 188 | category = "Extra Commands", 189 | prefix_command, 190 | slash_command, 191 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 192 | )] 193 | pub async fn botstats(ctx: Context<'_>) -> CommandResult { 194 | let data = ctx.data(); 195 | let cache = ctx.cache(); 196 | let bot_user_id = cache.current_user().id; 197 | 198 | let start_time = std::time::SystemTime::now(); 199 | let [sep1, sep2, ..] = OPTION_SEPERATORS; 200 | 201 | let guild_ids = cache.guilds(); 202 | let (total_guild_count, total_voice_clients, total_members) = { 203 | let guilds: Vec<_> = guild_ids.iter().filter_map(|id| cache.guild(*id)).collect(); 204 | 205 | ( 206 | guilds.len().to_formatted_string(&Locale::en), 207 | guilds 208 | .iter() 209 | .filter(|g| g.voice_states.contains_key(&bot_user_id)) 210 | .count() 211 | .to_arraystring(), 212 | guilds 213 | .into_iter() 214 | .map(|g| g.member_count) 215 | .sum::() 216 | .to_formatted_string(&Locale::en), 217 | ) 218 | }; 219 | 220 | let shard_count = cache.shard_count(); 221 | let ram_usage = { 222 | let mut system_info = data.system_info.lock(); 223 | system_info.refresh_specifics( 224 | sysinfo::RefreshKind::nothing() 225 | .with_processes(sysinfo::ProcessRefreshKind::nothing().with_memory()), 226 | ); 227 | 228 | let pid = sysinfo::get_current_pid().unwrap(); 229 | let process_memory = system_info.process(pid).unwrap().memory(); 230 | 231 | (process_memory / 1024 / 1024).to_formatted_string(&Locale::en) 232 | }; 233 | 234 | let neutral_colour = ctx.neutral_colour().await; 235 | let (embed_title, embed_thumbnail) = { 236 | let current_user = cache.current_user(); 237 | 238 | let title = format!("{}: Freshly rewritten in Rust!", current_user.name); 239 | let thumbnail = current_user.face(); 240 | 241 | (title, thumbnail) 242 | }; 243 | 244 | let time_to_fetch = start_time.elapsed()?.as_secs_f64() * 1000.0; 245 | let embed = CreateEmbed::default() 246 | .title(embed_title) 247 | .thumbnail(embed_thumbnail) 248 | .url(data.config.main_server_invite.clone()) 249 | .colour(neutral_colour) 250 | .footer(CreateEmbedFooter::new(format!( 251 | "Time to fetch: {time_to_fetch:.2}ms 252 | Support Server: {} 253 | Repository: https://github.com/Discord-TTS/Bot", 254 | data.config.main_server_invite 255 | ))) 256 | .description(format!( 257 | "Currently in: 258 | {sep2} {total_voice_clients} voice channels 259 | {sep2} {total_guild_count} servers 260 | Currently using: 261 | {sep1} {shard_count} shards 262 | {sep1} {ram_usage}MB of RAM 263 | and can be used by {total_members} people!", 264 | )); 265 | 266 | ctx.send(poise::CreateReply::default().embed(embed)).await?; 267 | Ok(()) 268 | } 269 | 270 | /// Shows the current setup channel! 271 | #[poise::command( 272 | category = "Extra Commands", 273 | guild_only, 274 | prefix_command, 275 | slash_command, 276 | required_bot_permissions = "SEND_MESSAGES" 277 | )] 278 | pub async fn channel(ctx: Context<'_>) -> CommandResult { 279 | let guild_id = ctx.guild_id().unwrap(); 280 | let guild_row = ctx.data().guilds_db.get(guild_id.into()).await?; 281 | 282 | let msg = if let Some(channel) = guild_row.channel 283 | && require_guild!(ctx).channels.contains_key(&channel) 284 | { 285 | if channel.widen() == ctx.channel_id() { 286 | "You are in the setup channel already!" 287 | } else { 288 | &aformat!("The current setup channel is: <#{channel}>") 289 | } 290 | } else { 291 | "The channel hasn't been setup, do `/setup #textchannel`" 292 | }; 293 | 294 | ctx.say(msg).await?; 295 | Ok(()) 296 | } 297 | 298 | /// Gets current ping to discord! 299 | #[poise::command( 300 | category = "Extra Commands", 301 | prefix_command, 302 | slash_command, 303 | required_bot_permissions = "SEND_MESSAGES", 304 | aliases("lag") 305 | )] 306 | pub async fn ping(ctx: Context<'_>) -> CommandResult { 307 | let ping_before = std::time::SystemTime::now(); 308 | let ping_msg = ctx.say("Loading!").await?; 309 | 310 | let msg = aformat!("Current Latency: {}ms", ping_before.elapsed()?.as_millis()); 311 | 312 | ping_msg 313 | .edit(ctx, CreateReply::default().content(msg.as_str())) 314 | .await?; 315 | 316 | Ok(()) 317 | } 318 | 319 | /// Sends the instructions to invite TTS Bot and join the support server! 320 | #[poise::command( 321 | category = "Extra Commands", 322 | prefix_command, 323 | slash_command, 324 | required_bot_permissions = "SEND_MESSAGES" 325 | )] 326 | pub async fn invite(ctx: Context<'_>) -> CommandResult { 327 | let cache = ctx.cache(); 328 | let config = &ctx.data().config; 329 | 330 | let bot_mention = cache.current_user().id.mention(); 331 | let msg = if ctx.guild_id() == Some(config.main_server) { 332 | &*aformat!( 333 | "Check out {} to invite {bot_mention}!", 334 | config.invite_channel.mention(), 335 | ) 336 | } else { 337 | let guild = cache.guild(config.main_server).try_unwrap()?; 338 | let channel = guild.channels.get(&config.invite_channel).try_unwrap()?; 339 | 340 | &format!( 341 | "Join {} and look in #{} to invite {bot_mention}", 342 | config.main_server_invite, channel.base.name, 343 | ) 344 | }; 345 | 346 | ctx.say(msg).await?; 347 | Ok(()) 348 | } 349 | 350 | pub fn commands() -> [Command; 8] { 351 | [ 352 | tts(), 353 | uptime(), 354 | botstats(), 355 | channel(), 356 | ping(), 357 | invite(), 358 | tts_speak(), 359 | tts_speak_as(), 360 | ] 361 | } 362 | -------------------------------------------------------------------------------- /tts_commands/src/main_.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ops::ControlFlow, 3 | sync::{Arc, atomic::Ordering}, 4 | }; 5 | 6 | use aformat::{ArrayString, aformat}; 7 | 8 | use poise::serenity_prelude::{self as serenity, builder::*, colours::branding::YELLOW}; 9 | use songbird::error::JoinError; 10 | 11 | use tts_core::{ 12 | common::{push_permission_names, random_footer}, 13 | constants::RED, 14 | database_models::GuildRow, 15 | opt_ext::OptionTryUnwrap as _, 16 | require_guild, 17 | structs::{Command, CommandResult, Context, JoinVCToken, Result}, 18 | traits::{PoiseContextExt, SongbirdManagerExt}, 19 | }; 20 | 21 | use crate::REQUIRED_VC_PERMISSIONS; 22 | 23 | /// Returns Some(GuildRow) on correct channel, otherwise None. 24 | async fn channel_check( 25 | ctx: &Context<'_>, 26 | author_vc: Option, 27 | ) -> Result>> { 28 | let guild_id = ctx.guild_id().unwrap(); 29 | let guild_row = ctx.data().guilds_db.get(guild_id.into()).await?; 30 | 31 | let channel_id = Some(ctx.channel_id().expect_channel()); 32 | if guild_row.channel == channel_id || author_vc == channel_id { 33 | return Ok(Some(guild_row)); 34 | } 35 | 36 | let msg = if let Some(setup_id) = guild_row.channel { 37 | let guild = require_guild!(ctx, Ok(None)); 38 | if guild.channels.contains_key(&setup_id) { 39 | &aformat!("You ran this command in the wrong channel, please move to <#{setup_id}>.") 40 | } else { 41 | "Your setup channel has been deleted, please run /setup!" 42 | } 43 | } else { 44 | "You haven't setup the bot, please run /setup!" 45 | }; 46 | 47 | ctx.send_error(msg).await?; 48 | Ok(None) 49 | } 50 | 51 | async fn handle_vc_mismatch( 52 | ctx: Context<'_>, 53 | guild_id: serenity::GuildId, 54 | author_vc: serenity::ChannelId, 55 | bot_id: serenity::UserId, 56 | bot_channel_id: songbird::id::ChannelId, 57 | ) -> Result> { 58 | let data = ctx.data(); 59 | 60 | let bot_channel_id = serenity::ChannelId::new(bot_channel_id.get()); 61 | let (channel_exists, voice_state_matches) = { 62 | let Some(guild) = ctx.guild() else { 63 | return Ok(ControlFlow::Break(())); 64 | }; 65 | 66 | let voice_state = guild.voice_states.get(&bot_id); 67 | 68 | ( 69 | guild.channels.contains_key(&bot_channel_id), 70 | voice_state.is_some_and(|vs| vs.channel_id == Some(bot_channel_id)), 71 | ) 72 | }; 73 | 74 | match (channel_exists, voice_state_matches) { 75 | (true, true) => { 76 | if author_vc == bot_channel_id { 77 | ctx.say("I am already in your voice channel!").await?; 78 | } else { 79 | let msg = aformat!("I am already in <#{bot_channel_id}>!"); 80 | ctx.say(msg.as_str()).await?; 81 | } 82 | 83 | Ok(ControlFlow::Break(())) 84 | } 85 | (false, _) => { 86 | tracing::warn!("Channel {bot_channel_id} didn't exist in {guild_id} in `/join`"); 87 | data.leave_vc(guild_id).await?; 88 | Ok(ControlFlow::Continue(())) 89 | } 90 | (_, false) => { 91 | tracing::warn!("Songbird thought it was in the wrong channel in {guild_id} in `/join`"); 92 | data.leave_vc(guild_id).await?; 93 | Ok(ControlFlow::Continue(())) 94 | } 95 | } 96 | } 97 | 98 | fn create_warning_embed<'a>(title: &'a str, footer: &'a str) -> serenity::CreateEmbed<'a> { 99 | serenity::CreateEmbed::default() 100 | .title(title) 101 | .colour(YELLOW) 102 | .footer(serenity::CreateEmbedFooter::new(footer)) 103 | } 104 | 105 | #[cold] 106 | fn gtts_disabled_embed<'a>( 107 | msg: poise::CreateReply<'a>, 108 | support_server: &'a str, 109 | ) -> poise::CreateReply<'a> { 110 | msg.embed( 111 | serenity::CreateEmbed::default() 112 | .title("The `gTTS` voice mode is globally disabled due to maintainance") 113 | .description("Any usage of this mode will instead use the lower quality `eSpeak` mode, while premium modes are unaffected.") 114 | .footer(serenity::CreateEmbedFooter::new(format!("Support server: {support_server}"))) 115 | .colour(RED) 116 | ) 117 | } 118 | 119 | #[cold] 120 | fn required_prefix_embed<'a>( 121 | title_place: &'a mut ArrayString<46>, 122 | msg: poise::CreateReply<'a>, 123 | required_prefix: ArrayString<8>, 124 | ) -> poise::CreateReply<'a> { 125 | *title_place = aformat!("Your TTS required prefix is set to: `{required_prefix}`"); 126 | let footer = "To disable the required prefix, use /set required_prefix with no options."; 127 | 128 | msg.embed(create_warning_embed(title_place.as_str(), footer)) 129 | } 130 | 131 | #[cold] 132 | fn required_role_embed<'a>( 133 | title_place: &'a mut ArrayString<133>, 134 | 135 | ctx: Context<'a>, 136 | msg: poise::CreateReply<'a>, 137 | required_role: serenity::RoleId, 138 | ) -> poise::CreateReply<'a> { 139 | let guild = ctx.guild(); 140 | let role_name = guild 141 | .as_deref() 142 | .and_then(|g| g.roles.get(&required_role).map(|r| r.name.as_str())) 143 | .unwrap_or("Unknown"); 144 | 145 | let role_name = aformat::CapStr::<100>(role_name); 146 | *title_place = aformat!("The required role for TTS is: `@{role_name}`"); 147 | let footer = "To disable the required role, use /set required_role with no options."; 148 | 149 | msg.embed(create_warning_embed(title_place.as_str(), footer)) 150 | } 151 | 152 | /// Joins the voice channel you're in! 153 | #[poise::command( 154 | category = "Main Commands", 155 | guild_only, 156 | prefix_command, 157 | slash_command, 158 | required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" 159 | )] 160 | pub async fn join(ctx: Context<'_>) -> CommandResult { 161 | let Some(author_vc) = ctx.author_vc() else { 162 | let err = "I cannot join your voice channel unless you are in one!"; 163 | ctx.send_error(err).await?; 164 | return Ok(()); 165 | }; 166 | 167 | let Some(guild_row) = channel_check(&ctx, Some(author_vc)).await? else { 168 | return Ok(()); 169 | }; 170 | 171 | let guild_id = ctx.guild_id().unwrap(); 172 | let (bot_id, bot_face) = { 173 | let current_user = ctx.cache().current_user(); 174 | (current_user.id, current_user.face()) 175 | }; 176 | 177 | let (author_vc_bot_perms, communication_disabled_until) = { 178 | let guild = require_guild!(ctx); 179 | let bot_member = guild.members.get(&bot_id).try_unwrap()?; 180 | let author_vc = guild.channels.get(&author_vc).try_unwrap()?; 181 | 182 | let bot_vc_perms = guild.user_permissions_in(author_vc, bot_member); 183 | (bot_vc_perms, bot_member.communication_disabled_until) 184 | }; 185 | 186 | if let Some(communication_disabled_until) = communication_disabled_until 187 | && communication_disabled_until > serenity::Timestamp::now() 188 | { 189 | let msg = "I am timed out, please ask a moderator to remove the timeout"; 190 | ctx.send_error(msg).await?; 191 | return Ok(()); 192 | } 193 | 194 | let missing_permissions = REQUIRED_VC_PERMISSIONS - author_vc_bot_perms; 195 | if !missing_permissions.is_empty() { 196 | let mut msg = String::from( 197 | "I do not have permission to TTS in your voice channel, please ask a server administrator to give me: ", 198 | ); 199 | push_permission_names(&mut msg, missing_permissions); 200 | 201 | ctx.send_error(msg).await?; 202 | return Ok(()); 203 | } 204 | 205 | let data = ctx.data(); 206 | if let Some(bot_vc) = data.songbird.get(guild_id) { 207 | let bot_channel_id = bot_vc.lock().await.current_channel(); 208 | if let Some(bot_channel_id) = bot_channel_id { 209 | let result = 210 | handle_vc_mismatch(ctx, guild_id, author_vc, bot_id, bot_channel_id).await?; 211 | 212 | if result.is_break() { 213 | return Ok(()); 214 | } 215 | } 216 | } 217 | 218 | let display_name = { 219 | let join_vc_lock = JoinVCToken::acquire(&data, guild_id); 220 | let (_typing, join_vc_result) = tokio::try_join!(ctx.defer_or_broadcast(), async { 221 | Ok(data.songbird.join_vc(join_vc_lock, author_vc).await) 222 | })?; 223 | 224 | if let Err(err) = join_vc_result { 225 | return if let JoinError::TimedOut = err { 226 | let msg = "I failed to join your voice channel, please check I have the right permissions and try again!"; 227 | ctx.send_error(msg).await?; 228 | Ok(()) 229 | } else { 230 | Err(err.into()) 231 | }; 232 | } 233 | 234 | match ctx { 235 | Context::Application(poise::ApplicationContext { interaction, .. }) => { 236 | interaction.member.as_deref().try_unwrap()?.display_name() 237 | } 238 | Context::Prefix(poise::PrefixContext { msg, .. }) => { 239 | let member = msg.member.as_deref().try_unwrap()?; 240 | member.nick.as_deref().unwrap_or(msg.author.display_name()) 241 | } 242 | } 243 | }; 244 | 245 | let embed = serenity::CreateEmbed::default() 246 | .title("Joined your voice channel!") 247 | .description("Just type normally and TTS Bot will say your messages!") 248 | .thumbnail(bot_face) 249 | .author(CreateEmbedAuthor::new(display_name).icon_url(ctx.author().face())) 250 | .footer(CreateEmbedFooter::new(random_footer( 251 | &data.config.main_server_invite, 252 | bot_id, 253 | ))); 254 | 255 | let mut msg = poise::CreateReply::default().embed(embed); 256 | 257 | // In-perfect premium check, but we don't need to be perfect 258 | if data.config.gtts_disabled.load(Ordering::Relaxed) && guild_row.premium_user.is_none() { 259 | msg = gtts_disabled_embed(msg, &data.config.main_server_invite); 260 | } 261 | 262 | let mut title_place = ArrayString::new(); 263 | if let Some(required_prefix) = guild_row.required_prefix { 264 | msg = required_prefix_embed(&mut title_place, msg, required_prefix); 265 | } 266 | 267 | let mut title_place = ArrayString::new(); 268 | if let Some(required_role) = guild_row.required_role { 269 | msg = required_role_embed(&mut title_place, ctx, msg, required_role); 270 | } 271 | 272 | ctx.send(msg).await?; 273 | Ok(()) 274 | } 275 | 276 | /// Leaves voice channel TTS Bot is in! 277 | #[poise::command( 278 | category = "Main Commands", 279 | guild_only, 280 | prefix_command, 281 | slash_command, 282 | required_bot_permissions = "SEND_MESSAGES" 283 | )] 284 | pub async fn leave(ctx: Context<'_>) -> CommandResult { 285 | let (guild_id, author_vc) = { 286 | let guild = require_guild!(ctx); 287 | let channel_id = guild 288 | .voice_states 289 | .get(&ctx.author().id) 290 | .and_then(|vs| vs.channel_id); 291 | 292 | (guild.id, channel_id) 293 | }; 294 | 295 | let data = ctx.data(); 296 | let bot_vc = { 297 | if let Some(handler) = data.songbird.get(guild_id) { 298 | handler.lock().await.current_channel() 299 | } else { 300 | None 301 | } 302 | }; 303 | 304 | if let Some(bot_vc) = bot_vc 305 | && channel_check(&ctx, author_vc).await?.is_some() 306 | { 307 | if author_vc.is_none_or(|author_vc| bot_vc.get() != author_vc.get()) { 308 | ctx.say("Error: You need to be in the same voice channel as me to make me leave!") 309 | .await?; 310 | } else { 311 | data.leave_vc(guild_id).await?; 312 | ctx.say("Left voice channel!").await?; 313 | } 314 | } 315 | 316 | Ok(()) 317 | } 318 | 319 | /// Clears the message queue! 320 | #[poise::command( 321 | aliases("skip"), 322 | category = "Main Commands", 323 | guild_only, 324 | prefix_command, 325 | slash_command, 326 | required_bot_permissions = "SEND_MESSAGES | ADD_REACTIONS" 327 | )] 328 | pub async fn clear(ctx: Context<'_>) -> CommandResult { 329 | if channel_check(&ctx, ctx.author_vc()).await?.is_none() { 330 | return Ok(()); 331 | } 332 | 333 | let guild_id = ctx.guild_id().unwrap(); 334 | if let Some(call_lock) = ctx.data().songbird.get(guild_id) { 335 | call_lock.lock().await.queue().stop(); 336 | 337 | match ctx { 338 | poise::Context::Prefix(ctx) => { 339 | // Prefixed command, just add a thumbsup reaction 340 | ctx.msg.react(ctx.http(), '👍').await?; 341 | } 342 | poise::Context::Application(_) => { 343 | // Slash command, no message to react to, just say thumbsup 344 | ctx.say("👍").await?; 345 | } 346 | } 347 | } else { 348 | ctx.say("**Error**: I am not in a voice channel!").await?; 349 | } 350 | 351 | Ok(()) 352 | } 353 | 354 | pub fn commands() -> [Command; 3] { 355 | [join(), leave(), clear()] 356 | } 357 | -------------------------------------------------------------------------------- /tts_core/src/common.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::num::NonZeroU8; 3 | 4 | use itertools::Itertools; 5 | use rand::Rng as _; 6 | 7 | use serenity::all as serenity; 8 | use serenity::{CollectComponentInteractions, CreateActionRow, CreateButton, CreateComponent}; 9 | 10 | use crate::structs::{ 11 | Context, Data, LastToXsaidTracker, LastXsaidInfo, RegexCache, Result, TTSMode, TTSServiceError, 12 | }; 13 | 14 | pub(crate) fn timestamp_in_future(ts: serenity::Timestamp) -> bool { 15 | *ts > chrono::Utc::now() 16 | } 17 | 18 | pub fn push_permission_names(buffer: &mut String, permissions: serenity::Permissions) { 19 | let permission_names = permissions.get_permission_names(); 20 | for (i, permission) in permission_names.iter().enumerate() { 21 | buffer.push_str(permission); 22 | if i != permission_names.len() - 1 { 23 | buffer.push_str(", "); 24 | } 25 | } 26 | } 27 | 28 | pub async fn remove_premium(data: &Data, guild_id: serenity::GuildId) -> Result<()> { 29 | tokio::try_join!( 30 | data.guilds_db 31 | .set_one(guild_id.into(), "premium_user", None::), 32 | data.guilds_db 33 | .set_one(guild_id.into(), "voice_mode", TTSMode::default()), 34 | )?; 35 | 36 | Ok(()) 37 | } 38 | 39 | pub async fn dm_generic( 40 | ctx: &serenity::Context, 41 | author: &serenity::User, 42 | target: serenity::UserId, 43 | mut target_tag: String, 44 | attachment_url: Option<&str>, 45 | message: &str, 46 | ) -> Result<(String, serenity::Embed)> { 47 | let mut embed = serenity::CreateEmbed::default(); 48 | if let Some(url) = attachment_url { 49 | embed = embed.image(url); 50 | } 51 | 52 | let embeds = [embed 53 | .title("Message from the developers:") 54 | .description(message) 55 | .author(serenity::CreateEmbedAuthor::new(author.tag()).icon_url(author.face()))]; 56 | 57 | let sent = target 58 | .dm( 59 | &ctx.http, 60 | serenity::CreateMessage::default().embeds(&embeds), 61 | ) 62 | .await?; 63 | 64 | target_tag.insert_str(0, "Sent message to: "); 65 | Ok((target_tag, sent.embeds.into_iter().next().unwrap())) 66 | } 67 | 68 | pub async fn fetch_audio( 69 | reqwest: &reqwest::Client, 70 | url: reqwest::Url, 71 | auth_key: Option<&str>, 72 | ) -> Result> { 73 | let resp = reqwest 74 | .get(url) 75 | .header(reqwest::header::AUTHORIZATION, auth_key.unwrap_or("")) 76 | .send() 77 | .await?; 78 | 79 | match resp.error_for_status_ref() { 80 | Ok(_) => Ok(Some(resp)), 81 | Err(backup_err) => match resp.json::().await { 82 | Ok(err) => { 83 | if err.code.should_ignore() { 84 | Ok(None) 85 | } else { 86 | Err(anyhow::anyhow!("Error fetching audio: {}", err.display)) 87 | } 88 | } 89 | Err(_) => Err(backup_err.into()), 90 | }, 91 | } 92 | } 93 | 94 | #[must_use] 95 | pub fn prepare_url( 96 | mut tts_service: reqwest::Url, 97 | content: &str, 98 | lang: &str, 99 | mode: TTSMode, 100 | speaking_rate: &str, 101 | max_length: &str, 102 | translation_lang: Option<&str>, 103 | ) -> reqwest::Url { 104 | { 105 | let mut params = tts_service.query_pairs_mut(); 106 | params.append_pair("text", content); 107 | params.append_pair("lang", lang); 108 | params.append_pair("mode", mode.into()); 109 | params.append_pair("max_length", max_length); 110 | params.append_pair("preferred_format", "mp3"); 111 | params.append_pair("speaking_rate", speaking_rate); 112 | 113 | if let Some(translation_lang) = translation_lang { 114 | params.append_pair("translation_lang", translation_lang); 115 | } 116 | 117 | params.finish(); 118 | } 119 | 120 | tts_service.set_path("tts"); 121 | tts_service 122 | } 123 | 124 | #[must_use] 125 | pub fn random_footer(server_invite: &str, client_id: serenity::UserId) -> Cow<'static, str> { 126 | match rand::rng().random_range(0..4) { 127 | 0 => Cow::Owned(format!( 128 | "If you find a bug or want to ask a question, join the support server: {server_invite}" 129 | )), 130 | 1 => Cow::Owned(format!( 131 | "You can vote for me or review me on top.gg!\nhttps://top.gg/bot/{client_id}" 132 | )), 133 | 2 => Cow::Borrowed( 134 | "If you want to support the development and hosting of TTS Bot, check out `/premium`!", 135 | ), 136 | 3 => Cow::Borrowed("There are loads of customizable settings, check out `/help set`"), 137 | _ => unreachable!(), 138 | } 139 | } 140 | 141 | fn strip_emoji<'c>(regex_cache: &RegexCache, content: &'c str) -> Cow<'c, str> { 142 | regex_cache.emoji_filter.replace_all(content, "") 143 | } 144 | 145 | fn make_emoji_readable<'c>(regex_cache: &RegexCache, content: &'c str) -> Cow<'c, str> { 146 | regex_cache 147 | .emoji_captures 148 | .replace_all(content, |re_match: ®ex::Captures<'_>| { 149 | let is_animated = re_match.get(1).unwrap().as_str(); 150 | let emoji_name = re_match.get(2).unwrap().as_str(); 151 | 152 | let emoji_prefix = if is_animated.is_empty() { 153 | "emoji" 154 | } else { 155 | "animated emoji" 156 | }; 157 | 158 | format!("{emoji_prefix} {emoji_name}") 159 | }) 160 | } 161 | 162 | fn parse_acronyms(original: &str) -> String { 163 | original 164 | .split(' ') 165 | .map(|word| match word { 166 | "iirc" => "if I recall correctly", 167 | "afaik" => "as far as I know", 168 | "wdym" => "what do you mean", 169 | "imo" => "in my opinion", 170 | "brb" => "be right back", 171 | "wym" => "what you mean", 172 | "irl" => "in real life", 173 | "jk" => "just kidding", 174 | "btw" => "by the way", 175 | ":)" => "smiley face", 176 | "gtg" => "got to go", 177 | "rn" => "right now", 178 | ":(" => "sad face", 179 | "ig" => "i guess", 180 | "ppl" => "people", 181 | "rly" => "really", 182 | "cya" => "see ya", 183 | "ik" => "i know", 184 | "@" => "at", 185 | "™️" => "tm", 186 | _ => word, 187 | }) 188 | .join(" ") 189 | } 190 | 191 | fn attachments_to_format(attachments: &[serenity::Attachment]) -> Option<&'static str> { 192 | if attachments.len() >= 2 { 193 | return Some("multiple files"); 194 | } 195 | 196 | let extension = attachments.first()?.filename.split('.').next_back()?; 197 | match extension { 198 | "bmp" | "gif" | "ico" | "png" | "psd" | "svg" | "jpg" => Some("an image file"), 199 | "mid" | "midi" | "mp3" | "ogg" | "wav" | "wma" => Some("an audio file"), 200 | "avi" | "mp4" | "wmv" | "m4v" | "mpg" | "mpeg" => Some("a video file"), 201 | "zip" | "7z" | "rar" | "gz" | "xz" => Some("a compressed file"), 202 | "doc" | "docx" | "txt" | "odt" | "rtf" => Some("a text file"), 203 | "bat" | "sh" | "jar" | "py" | "php" => Some("a script file"), 204 | "apk" | "exe" | "msi" | "deb" => Some("a program file"), 205 | "dmg" | "iso" | "img" | "ima" => Some("a disk image"), 206 | _ => Some("a file"), 207 | } 208 | } 209 | 210 | fn remove_repeated_chars(content: &str, limit: u8) -> String { 211 | let mut out = String::new(); 212 | for (_, group) in &content.chars().chunk_by(|&c| c) { 213 | out.extend(group.take(usize::from(limit))); 214 | } 215 | 216 | out 217 | } 218 | 219 | #[allow(clippy::too_many_arguments)] 220 | pub fn clean_msg( 221 | content: &str, 222 | 223 | user: &serenity::User, 224 | cache: &serenity::Cache, 225 | guild_id: serenity::GuildId, 226 | member_nick: Option<&str>, 227 | attachments: &[serenity::Attachment], 228 | 229 | voice: &str, 230 | xsaid: bool, 231 | skip_emoji: bool, 232 | repeated_limit: Option, 233 | nickname: Option<&str>, 234 | use_new_formatting: bool, 235 | 236 | regex_cache: &RegexCache, 237 | last_to_xsaid_tracker: &LastToXsaidTracker, 238 | ) -> String { 239 | let (contained_url, mut content) = if content == "?" { 240 | (false, String::from("what")) 241 | } else { 242 | let mut content = if skip_emoji { 243 | strip_emoji(regex_cache, content) 244 | } else { 245 | make_emoji_readable(regex_cache, content) 246 | }; 247 | 248 | for (regex, replacement) in ®ex_cache.replacements { 249 | if let Cow::Owned(replaced) = regex.replace_all(&content, *replacement) { 250 | content = Cow::Owned(replaced); 251 | } 252 | } 253 | 254 | if voice.starts_with("en") { 255 | content = Cow::Owned(parse_acronyms(&content)); 256 | } 257 | 258 | let filtered_content: String = linkify::LinkFinder::new() 259 | .spans(&content) 260 | .filter(|span| span.kind().is_none()) 261 | .map(|span| span.as_str()) 262 | .collect(); 263 | 264 | (content != filtered_content, filtered_content) 265 | }; 266 | 267 | let announce_name = xsaid 268 | && last_to_xsaid_tracker.get(&guild_id).is_none_or(|state| { 269 | let guild = cache.guild(guild_id).unwrap(); 270 | state.should_announce_name(&guild, user.id) 271 | }); 272 | 273 | let attached_file_format = attachments_to_format(attachments); 274 | let said_name = announce_name.then(|| { 275 | nickname 276 | .or(member_nick) 277 | .or(user.global_name.as_deref()) 278 | .unwrap_or(&user.name) 279 | }); 280 | 281 | if use_new_formatting { 282 | format_message(&mut content, said_name, contained_url, attached_file_format); 283 | } else { 284 | format_message_legacy(&mut content, said_name, contained_url, attached_file_format); 285 | } 286 | 287 | if xsaid { 288 | last_to_xsaid_tracker.insert(guild_id, LastXsaidInfo::new(user.id)); 289 | } 290 | 291 | if let Some(repeated_limit) = repeated_limit { 292 | content = remove_repeated_chars(&content, repeated_limit.get()); 293 | } 294 | 295 | content 296 | } 297 | 298 | pub fn format_message_legacy( 299 | content: &mut String, 300 | said_name: Option<&str>, 301 | contained_url: bool, 302 | attached_file_format: Option<&str>, 303 | ) { 304 | use std::fmt::Write; 305 | 306 | if let Some(said_name) = said_name { 307 | if contained_url { 308 | let suffix = if content.is_empty() { 309 | "a link." 310 | } else { 311 | "and sent a link" 312 | }; 313 | 314 | write!(content, " {suffix}",).unwrap(); 315 | } 316 | 317 | *content = match attached_file_format { 318 | Some(file_format) if content.is_empty() => format!("{said_name} sent {file_format}"), 319 | Some(file_format) => format!("{said_name} sent {file_format} and said {content}"), 320 | None => format!("{said_name} said: {content}"), 321 | } 322 | } else if contained_url { 323 | let suffix = if content.is_empty() { 324 | " a link." 325 | } else { 326 | ". This message contained a link" 327 | }; 328 | 329 | write!(content, "{suffix}",).unwrap(); 330 | } 331 | } 332 | 333 | pub fn format_message( 334 | content: &mut String, 335 | said_name: Option<&str>, 336 | contained_url: bool, 337 | attached_file_format: Option<&str>, 338 | ) { 339 | match ( 340 | said_name, 341 | content.trim(), 342 | contained_url, 343 | attached_file_format, 344 | ) { 345 | (Some(said_name), "", true, Some(format)) => { 346 | *content = format!("{said_name} sent a link and attached {format}"); 347 | } 348 | (Some(said_name), "", true, None) => { 349 | *content = format!("{said_name} sent a link"); 350 | } 351 | (Some(said_name), "", false, Some(format)) => { 352 | *content = format!("{said_name} sent {format}"); 353 | } 354 | // Fallback, this shouldn't occur 355 | (Some(said_name), "", false, None) => { 356 | *content = format!("{said_name} sent a message"); 357 | } 358 | (Some(said_name), msg, true, Some(format)) => { 359 | *content = format!("{said_name} sent a link, attached {format}, and said {msg}"); 360 | } 361 | (Some(said_name), msg, true, None) => { 362 | *content = format!("{said_name} sent a link and said {msg}"); 363 | } 364 | (Some(said_name), msg, false, Some(format)) => { 365 | *content = format!("{said_name} sent {format} and said {msg}"); 366 | } 367 | (Some(said_name), msg, false, None) => { 368 | *content = format!("{said_name} said: {msg}"); 369 | } 370 | (None, "", true, Some(format)) => { 371 | *content = format!("A link and {format}"); 372 | } 373 | (None, "", true, None) => { 374 | "A link".clone_into(content); 375 | } 376 | (None, "", false, Some(format)) => { 377 | format.clone_into(content); 378 | } 379 | // Again, fallback, there is nothing to say 380 | (None, "", false, None) => {} 381 | (None, msg, true, Some(format)) => { 382 | *content = format!("{msg} with {format} and a link"); 383 | } 384 | (None, msg, true, None) => { 385 | *content = format!("{msg} with a link"); 386 | } 387 | (None, msg, false, Some(format)) => { 388 | *content = format!("{msg} with {format}"); 389 | } 390 | (None, _msg, false, None) => {} 391 | } 392 | } 393 | 394 | pub fn confirm_dialog_buttons<'a>(positive: &'a str, negative: &'a str) -> [CreateButton<'a>; 2] { 395 | [ 396 | CreateButton::new("True") 397 | .style(serenity::ButtonStyle::Success) 398 | .label(positive), 399 | CreateButton::new("False") 400 | .style(serenity::ButtonStyle::Danger) 401 | .label(negative), 402 | ] 403 | } 404 | 405 | pub async fn confirm_dialog_wait( 406 | ctx: &serenity::Context, 407 | message_id: serenity::MessageId, 408 | author_id: serenity::UserId, 409 | ) -> Result> { 410 | let interaction = message_id 411 | .collect_component_interactions(ctx) 412 | .timeout(std::time::Duration::from_secs(60 * 5)) 413 | .author_id(author_id) 414 | .await; 415 | 416 | if let Some(interaction) = interaction { 417 | interaction.defer(&ctx.http).await?; 418 | match &*interaction.data.custom_id { 419 | "True" => Ok(Some(true)), 420 | "False" => Ok(Some(false)), 421 | _ => unreachable!(), 422 | } 423 | } else { 424 | Ok(None) 425 | } 426 | } 427 | 428 | pub async fn confirm_dialog( 429 | ctx: Context<'_>, 430 | prompt: &str, 431 | positive: &str, 432 | negative: &str, 433 | ) -> Result> { 434 | let buttons = confirm_dialog_buttons(positive, negative); 435 | let components = CreateComponent::ActionRow(CreateActionRow::buttons(&buttons)); 436 | let builder = poise::CreateReply::default() 437 | .content(prompt) 438 | .ephemeral(true) 439 | .components(std::slice::from_ref(&components)); 440 | 441 | let reply = ctx.send(builder).await?; 442 | let message = reply.message().await?; 443 | 444 | confirm_dialog_wait(ctx.serenity_context(), message.id, ctx.author().id).await 445 | } 446 | 447 | /// Avoid char boundary panics with utf8 chars 448 | pub fn safe_truncate(string: &mut String, mut new_len: usize) { 449 | if string.len() <= new_len { 450 | return; 451 | } 452 | 453 | new_len -= 3; 454 | while !string.is_char_boundary(new_len) { 455 | new_len -= 1; 456 | } 457 | 458 | string.truncate(new_len); 459 | string.push_str("..."); 460 | } 461 | -------------------------------------------------------------------------------- /tts_commands/src/owner.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Write, hash::Hash, time::Duration}; 2 | 3 | use aformat::{ToArrayString, aformat}; 4 | use futures_channel::mpsc::UnboundedSender; 5 | use num_format::{Locale, ToFormattedString}; 6 | use typesize::TypeSize; 7 | 8 | use crate::{REQUIRED_SETUP_PERMISSIONS, REQUIRED_VC_PERMISSIONS}; 9 | 10 | use self::serenity::{ 11 | CollectComponentInteractions, 12 | builder::*, 13 | small_fixed_array::{FixedArray, FixedString}, 14 | }; 15 | use poise::{CreateReply, serenity_prelude as serenity}; 16 | 17 | use tts_core::{ 18 | common::{dm_generic, safe_truncate}, 19 | database, 20 | database_models::Compact, 21 | structs::{Command, CommandResult, Context, PrefixContext, TTSModeChoice}, 22 | }; 23 | 24 | #[poise::command(prefix_command, owners_only, hide_in_help)] 25 | pub async fn register(ctx: Context<'_>) -> CommandResult { 26 | poise::samples::register_application_commands(ctx, true).await?; 27 | Ok(()) 28 | } 29 | 30 | #[poise::command(prefix_command, hide_in_help, owners_only)] 31 | pub async fn dm( 32 | ctx: PrefixContext<'_>, 33 | todm: serenity::User, 34 | #[rest] message: FixedString, 35 | ) -> CommandResult { 36 | let attachment_url = ctx.msg.attachments.first().map(|a| a.url.as_str()); 37 | let (content, embed) = dm_generic( 38 | ctx.serenity_context(), 39 | &ctx.msg.author, 40 | todm.id, 41 | todm.tag().into_owned(), 42 | attachment_url, 43 | &message, 44 | ) 45 | .await?; 46 | 47 | ctx.msg 48 | .channel_id 49 | .send_message( 50 | ctx.http(), 51 | CreateMessage::default() 52 | .content(content) 53 | .add_embed(CreateEmbed::from(embed)), 54 | ) 55 | .await?; 56 | 57 | Ok(()) 58 | } 59 | 60 | #[poise::command( 61 | prefix_command, 62 | owners_only, 63 | hide_in_help, 64 | aliases("invalidate_cache", "delete_cache"), 65 | subcommands("guild", "user", "guild_voice", "user_voice") 66 | )] 67 | pub async fn remove_cache(ctx: Context<'_>) -> CommandResult { 68 | ctx.say("Please run a subcommand!").await?; 69 | Ok(()) 70 | } 71 | 72 | #[poise::command(prefix_command, owners_only, hide_in_help)] 73 | pub async fn guild(ctx: Context<'_>, guild: i64) -> CommandResult { 74 | ctx.data().guilds_db.invalidate_cache(&guild); 75 | ctx.say("Done!").await?; 76 | Ok(()) 77 | } 78 | 79 | #[poise::command(prefix_command, owners_only, hide_in_help)] 80 | pub async fn user(ctx: Context<'_>, user: i64) -> CommandResult { 81 | ctx.data().userinfo_db.invalidate_cache(&user); 82 | ctx.say("Done!").await?; 83 | Ok(()) 84 | } 85 | 86 | #[poise::command(prefix_command, owners_only, hide_in_help)] 87 | pub async fn guild_voice(ctx: Context<'_>, guild: i64, mode: TTSModeChoice) -> CommandResult { 88 | ctx.data() 89 | .guild_voice_db 90 | .invalidate_cache(&(guild, mode.into())); 91 | ctx.say("Done!").await?; 92 | Ok(()) 93 | } 94 | 95 | #[poise::command(prefix_command, owners_only, hide_in_help)] 96 | pub async fn user_voice(ctx: Context<'_>, user: i64, mode: TTSModeChoice) -> CommandResult { 97 | ctx.data() 98 | .user_voice_db 99 | .invalidate_cache(&(user, mode.into())); 100 | ctx.say("Done!").await?; 101 | Ok(()) 102 | } 103 | 104 | #[poise::command(prefix_command, owners_only, hide_in_help)] 105 | pub async fn refresh_ofs(ctx: Context<'_>) -> CommandResult { 106 | let data = ctx.data(); 107 | let http = &ctx.http(); 108 | let cache = &ctx.cache(); 109 | 110 | let support_guild_id = data.config.main_server; 111 | let support_guild_members = support_guild_id.members(http, None, None).await?; 112 | 113 | let all_guild_owners = cache 114 | .guilds() 115 | .iter() 116 | .filter_map(|id| cache.guild(*id).map(|g| g.owner_id)) 117 | .collect::>(); 118 | 119 | let current_ofs_members = support_guild_members 120 | .iter() 121 | .filter(|m| m.roles.contains(&data.config.ofs_role)) 122 | .map(|m| m.user.id) 123 | .collect::>(); 124 | 125 | let should_not_be_ofs_members = current_ofs_members 126 | .iter() 127 | .filter(|ofs_member| !all_guild_owners.contains(ofs_member)); 128 | let should_be_ofs_members = all_guild_owners.iter().filter(|owner| { 129 | (!current_ofs_members.contains(owner)) 130 | && support_guild_members.iter().any(|m| m.user.id == **owner) 131 | }); 132 | 133 | let mut added_role: u64 = 0; 134 | for member in should_be_ofs_members { 135 | added_role += 1; 136 | http.add_member_role(support_guild_id, *member, data.config.ofs_role, None) 137 | .await?; 138 | } 139 | 140 | let mut removed_role: u64 = 0; 141 | for member in should_not_be_ofs_members { 142 | removed_role += 1; 143 | http.remove_member_role(support_guild_id, *member, data.config.ofs_role, None) 144 | .await?; 145 | } 146 | 147 | ctx.say( 148 | aformat!("Done! Removed {removed_role} members and added {added_role} members!").as_str(), 149 | ) 150 | .await?; 151 | Ok(()) 152 | } 153 | 154 | /// Debug commands for the bot 155 | #[poise::command( 156 | prefix_command, 157 | slash_command, 158 | guild_only, 159 | subcommands("info", "leave") 160 | )] 161 | pub async fn debug(ctx: Context<'_>) -> CommandResult { 162 | info_(ctx).await 163 | } 164 | 165 | /// Shows debug information including voice info and database info. 166 | #[poise::command(prefix_command, slash_command, guild_only)] 167 | pub async fn info(ctx: Context<'_>) -> CommandResult { 168 | info_(ctx).await 169 | } 170 | 171 | pub async fn info_(ctx: Context<'_>) -> CommandResult { 172 | let guild_id = ctx.guild_id().unwrap(); 173 | let guild_id_db: i64 = guild_id.into(); 174 | 175 | let data = ctx.data(); 176 | let author_id = ctx.author().id.into(); 177 | 178 | let shard_id = ctx.serenity_context().shard_id; 179 | let user_row = data.userinfo_db.get(author_id).await?; 180 | let guild_row = data.guilds_db.get(guild_id_db).await?; 181 | let nick_row = data.nickname_db.get([guild_id_db, author_id]).await?; 182 | let guild_voice_row = data 183 | .guild_voice_db 184 | .get((guild_id_db, guild_row.voice_mode)) 185 | .await?; 186 | 187 | let user_voice_row = data 188 | .user_voice_db 189 | .get((author_id, user_row.voice_mode.unwrap_or_default())) 190 | .await?; 191 | 192 | let voice_client = data.songbird.get(guild_id); 193 | let embed = CreateEmbed::default() 194 | .title("TTS Bot Debug Info") 195 | .description(format!( 196 | " 197 | Shard ID: `{shard_id}` 198 | Voice Client: `{voice_client:?}` 199 | 200 | Server Data: `{guild_row:?}` 201 | User Data: `{user_row:?}` 202 | Nickname Data: `{nick_row:?}` 203 | User Voice Data: `{user_voice_row:?}` 204 | Guild Voice Data: `{guild_voice_row:?}` 205 | " 206 | )); 207 | 208 | ctx.send(poise::CreateReply::default().embed(embed)).await?; 209 | Ok(()) 210 | } 211 | 212 | /// Force leaves the voice channel in the current server to bypass buggy states 213 | #[poise::command(prefix_command, guild_only, hide_in_help)] 214 | pub async fn leave(ctx: Context<'_>) -> CommandResult { 215 | let guild_id = ctx.guild_id().unwrap(); 216 | ctx.data().leave_vc(guild_id).await.map_err(Into::into) 217 | } 218 | 219 | fn get_db_info( 220 | name: &'static str, 221 | handler: &database::Handler, 222 | ) -> typesize::Field 223 | where 224 | CacheKey: Eq + Hash + TypeSize, 225 | RowT::Compacted: TypeSize, 226 | RowT: Compact, 227 | { 228 | typesize::Field { 229 | name, 230 | size: handler.get_size(), 231 | collection_items: handler.get_collection_item_count(), 232 | } 233 | } 234 | 235 | fn guild_iter(cache: &serenity::Cache) -> impl Iterator> { 236 | cache.guilds().into_iter().filter_map(|id| cache.guild(id)) 237 | } 238 | 239 | fn details_iter<'a>( 240 | iter: impl Iterator, 241 | ) -> Vec> { 242 | iter.map(TypeSize::get_size_details).collect::>() 243 | } 244 | 245 | fn average_details(iter: impl Iterator>) -> Vec { 246 | let mut i = 1; 247 | let summed_details = iter.fold(Vec::new(), |mut avg_details, details| { 248 | if avg_details.is_empty() { 249 | return details; 250 | } 251 | 252 | // get_size_details should return the same amount of fields every time 253 | assert_eq!(avg_details.len(), details.len()); 254 | 255 | i += 1; 256 | for (avg, cur) in avg_details.iter_mut().zip(details) { 257 | avg.size += cur.size; 258 | if let Some(collection_items) = &mut avg.collection_items { 259 | *collection_items += cur.collection_items.unwrap(); 260 | } 261 | } 262 | 263 | avg_details 264 | }); 265 | 266 | let details = summed_details.into_iter().map(move |mut field| { 267 | if let Some(collection_items) = &mut field.collection_items { 268 | *collection_items /= i; 269 | } 270 | field.size /= i; 271 | field 272 | }); 273 | 274 | details.collect() 275 | } 276 | 277 | struct Field { 278 | name: String, 279 | size: usize, 280 | value: String, 281 | is_collection: bool, 282 | } 283 | 284 | fn process_cache_info( 285 | serenity_cache: &serenity::Cache, 286 | kind: Option<&str>, 287 | db_info: Option>, 288 | ) -> Option> { 289 | let cache_stats = match kind { 290 | Some("db") => Some(db_info.expect("if kind is db, db_info should be filled")), 291 | Some("guild") => Some(average_details( 292 | guild_iter(serenity_cache).map(|g| g.get_size_details()), 293 | )), 294 | Some("channel") => Some(average_details( 295 | guild_iter(serenity_cache).flat_map(|g| details_iter(g.channels.iter())), 296 | )), 297 | Some("role") => Some(average_details( 298 | guild_iter(serenity_cache).flat_map(|g| details_iter(g.roles.iter())), 299 | )), 300 | Some(_) => None, 301 | None => Some(serenity_cache.get_size_details()), 302 | }; 303 | 304 | let mut fields = Vec::new(); 305 | for field in cache_stats? { 306 | let name = format!("`{}`", field.name); 307 | let size = field.size.to_formatted_string(&Locale::en); 308 | if let Some(count) = field.collection_items { 309 | let (count, size_per) = if count == 0 { 310 | (Cow::Borrowed("0"), Cow::Borrowed("N/A")) 311 | } else { 312 | let count_fmt = count.to_formatted_string(&Locale::en); 313 | let mut size_per = (field.size / count).to_formatted_string(&Locale::en); 314 | size_per.push('b'); 315 | 316 | (Cow::Owned(count_fmt), Cow::Owned(size_per)) 317 | }; 318 | 319 | fields.push(Field { 320 | name, 321 | size: field.size, 322 | is_collection: true, 323 | value: format!("Size: `{size}b`\nCount: `{count}`\nSize per model: `{size_per}`"), 324 | }); 325 | } else { 326 | fields.push(Field { 327 | name, 328 | size: field.size, 329 | is_collection: false, 330 | value: format!("Size: `{size}b`"), 331 | }); 332 | } 333 | } 334 | 335 | fields.sort_by_key(|field| field.size); 336 | fields.sort_by_key(|field| field.is_collection); 337 | fields.reverse(); 338 | Some(fields) 339 | } 340 | 341 | #[poise::command(prefix_command, owners_only, hide_in_help)] 342 | pub async fn cache_info(ctx: Context<'_>, kind: Option) -> CommandResult { 343 | ctx.defer().await?; 344 | 345 | let db_info = if kind.as_deref() == Some("db") { 346 | let data = ctx.data(); 347 | Some(vec![ 348 | get_db_info("guild db", &data.guilds_db), 349 | get_db_info("userinfo db", &data.userinfo_db), 350 | get_db_info("nickname db", &data.nickname_db), 351 | get_db_info("user voice db", &data.user_voice_db), 352 | get_db_info("guild voice db", &data.guild_voice_db), 353 | ]) 354 | } else { 355 | None 356 | }; 357 | 358 | let cache = ctx.serenity_context().cache.clone(); 359 | let get_cache_info = move || process_cache_info(&cache, kind.as_deref(), db_info); 360 | let Some(fields) = tokio::task::spawn_blocking(get_cache_info).await.unwrap() else { 361 | ctx.say("Unknown cache!").await?; 362 | return Ok(()); 363 | }; 364 | 365 | let embed = CreateEmbed::default() 366 | .title("Cache Statistics") 367 | .fields(fields.into_iter().take(25).map(|f| (f.name, f.value, true))); 368 | 369 | ctx.send(CreateReply::default().embed(embed)).await?; 370 | Ok(()) 371 | } 372 | 373 | fn filter_channels_by<'a>( 374 | guild: &'a serenity::Guild, 375 | bot_member: &'a serenity::Member, 376 | kind: serenity::ChannelType, 377 | required_permissions: serenity::Permissions, 378 | ) -> impl Iterator + use<'a> { 379 | guild 380 | .channels 381 | .iter() 382 | .filter(move |c| c.base.kind == kind) 383 | .filter(move |c| { 384 | let channel_permissions = guild.user_permissions_in(c, bot_member); 385 | (required_permissions - channel_permissions).is_empty() 386 | }) 387 | } 388 | 389 | fn format_channels<'a>(channels: impl Iterator) -> String { 390 | let mut out = String::new(); 391 | for channel in channels { 392 | writeln!(out, "`{}`: {}", channel.id, channel.base.name).unwrap(); 393 | if out.len() >= 1024 { 394 | break; 395 | } 396 | } 397 | 398 | safe_truncate(&mut out, 1024); 399 | out 400 | } 401 | 402 | fn get_runner_channel( 403 | ctx: &serenity::Context, 404 | shard_id: serenity::ShardId, 405 | ) -> Option> { 406 | ctx.runners.get(&shard_id).map(|entry| entry.1.clone()) 407 | } 408 | 409 | #[poise::command(prefix_command, owners_only, hide_in_help)] 410 | pub async fn guild_info(ctx: Context<'_>, guild_id: Option) -> CommandResult { 411 | let cache = ctx.cache(); 412 | let Some(guild_id) = guild_id.or(ctx.guild_id()) else { 413 | ctx.say("Missing guild id!").await?; 414 | return Ok(()); 415 | }; 416 | 417 | let guild_shard_id = guild_id.shard_id(cache.shard_count()); 418 | 419 | let title = aformat!("Guild Report for {guild_id}"); 420 | let footer = aformat!("Guild Shard Id: {guild_shard_id}"); 421 | 422 | let mut embed = CreateEmbed::new() 423 | .footer(CreateEmbedFooter::new(&*footer)) 424 | .title(&*title); 425 | 426 | let mut permissions_formatted; 427 | let mut guild_cached = false; 428 | if let Some(guild) = cache.guild(guild_id) { 429 | guild_cached = true; 430 | if let Some(member) = guild.members.get(&cache.current_user().id) { 431 | let permissions = guild.member_permissions(member); 432 | let permissions = if permissions.administrator() { 433 | "Administrator" 434 | } else { 435 | permissions_formatted = permissions.to_string(); 436 | safe_truncate(&mut permissions_formatted, 256); 437 | &permissions_formatted 438 | }; 439 | 440 | let visible_text_channels = format_channels(filter_channels_by( 441 | &guild, 442 | member, 443 | serenity::ChannelType::Text, 444 | REQUIRED_SETUP_PERMISSIONS, 445 | )); 446 | 447 | let usable_voice_channels = format_channels(filter_channels_by( 448 | &guild, 449 | member, 450 | serenity::ChannelType::Voice, 451 | REQUIRED_VC_PERMISSIONS, 452 | )); 453 | 454 | embed = embed 455 | .field("Guild Permissions", permissions, false) 456 | .field("Visible Text Channels", visible_text_channels, true) 457 | .field("Usable Voice Channels", usable_voice_channels, true); 458 | } else { 459 | embed = embed.description("Guild is cached, but has no bot member"); 460 | } 461 | } 462 | 463 | if !guild_cached { 464 | let guild_fetchable = ctx.http().get_guild(guild_id).await.is_ok(); 465 | 466 | embed = embed.description(if guild_fetchable { 467 | "Guild is fetchable, but not in cache" 468 | } else { 469 | "Guild is not in cache and not fetchable, is TTS Bot in this guild?" 470 | }); 471 | } 472 | 473 | let custom_id = uuid::Uuid::now_v7().to_u128_le().to_arraystring(); 474 | let custom_ids = FixedArray::from_vec_trunc(vec![FixedString::from_str_trunc(&custom_id)]); 475 | 476 | let restart_button = CreateButton::new(&*custom_id) 477 | .style(serenity::ButtonStyle::Danger) 478 | .label("Restart Shard") 479 | .emoji('♻'); 480 | 481 | let action_row = CreateActionRow::buttons(std::slice::from_ref(&restart_button)); 482 | let components = CreateComponent::ActionRow(action_row); 483 | 484 | let reply = CreateReply::new() 485 | .embed(embed) 486 | .components(std::slice::from_ref(&components)); 487 | 488 | ctx.send(reply).await?; 489 | 490 | let response = ctx 491 | .channel_id() 492 | .collect_component_interactions(ctx.serenity_context()) 493 | .timeout(Duration::from_secs(60 * 5)) 494 | .author_id(ctx.author().id) 495 | .custom_ids(custom_ids) 496 | .await; 497 | 498 | let Some(interaction) = response else { 499 | return Ok(()); 500 | }; 501 | 502 | let http = ctx.http(); 503 | let _ = interaction.defer(http).await; 504 | 505 | let shard_id = serenity::ShardId(guild_shard_id); 506 | let Some(channel) = get_runner_channel(ctx.serenity_context(), shard_id) else { 507 | let message = CreateInteractionResponseFollowup::new() 508 | .content("No shard runner found in runners map, cannot restart!"); 509 | 510 | interaction.create_followup(http, message).await?; 511 | return Ok(()); 512 | }; 513 | 514 | let restart_msg = serenity::ShardRunnerMessage::Restart; 515 | if channel.unbounded_send(restart_msg).is_err() { 516 | let message = CreateInteractionResponseFollowup::new() 517 | .content("Shard runner channel does not exist anymore"); 518 | 519 | interaction.create_followup(http, message).await?; 520 | return Ok(()); 521 | } 522 | 523 | let message = CreateInteractionResponseFollowup::new() 524 | .content("Shard has been told to restart, let's see what happens."); 525 | 526 | interaction.create_followup(http, message).await?; 527 | 528 | Ok(()) 529 | } 530 | 531 | pub fn commands() -> [Command; 7] { 532 | [ 533 | dm(), 534 | debug(), 535 | register(), 536 | remove_cache(), 537 | refresh_ofs(), 538 | cache_info(), 539 | guild_info(), 540 | ] 541 | } 542 | --------------------------------------------------------------------------------