use poise::serenity_prelude as serenity; use crate::Data; use crate::error::{Error, Result}; use crate::lang; use tracing::warn; type Context<'a> = poise::Context<'a, Data, Error>; #[derive(poise::ChoiceParameter)] pub enum OutputMode { #[name = "File"] File, #[name = "Voice Channel"] Vc, } /// Generate TTS (local Piper) and output to file or voice channel #[poise::command(slash_command)] pub async fn tts( ctx: Context<'_>, #[description = "Text to synthesize"] text: String, #[description = "Output destination (file or vc)"] output: Option, ) -> Result<()> { ctx.defer().await?; let output = output.unwrap_or(OutputMode::Vc); let audio_data = ctx.data().tts.synthesize(&text).await?; match output { OutputMode::File => { let wav = crate::tts::encode_wav(&audio_data, 48000, 2); // Attach the bytes directly: no temp file to leak and no panic if // an external cleaner removes it between write and attach. if let Err(e) = ctx .send( poise::CreateReply::default() .content(lang::L_TTS_SAVED) .attachment(serenity::CreateAttachment::bytes(wav, "tts.wav")), ) .await { warn!("Failed to send /tts file reply: {e}"); } } OutputMode::Vc => { let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx .serenity_context() .cache .guild(guild_id) .and_then(|g| { g.voice_states .get(&ctx.author().id) .and_then(|vs| vs.channel_id) }) .ok_or_else(lang::err_not_in_voice)?; let call = ctx .data() .voice_manager .connect(guild_id, channel_id) .await?; let input = crate::tts::pcm_bytes_to_input(audio_data); let mut call_lock = call.lock().await; if call_lock.current_channel().is_some() { call_lock.play_input(input); } if let Err(e) = ctx.say(lang::L_TTS_PLAYING).await { warn!("Failed to send /tts voice reply: {e}"); } } } Ok(()) } pub fn commands() -> Vec> { vec![tts()] }