diff --git a/.gitmodules b/.gitmodules index fc0750a..042d425 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "assets/rust-examples"] path = assets/rust-examples url = https://github.com/CrawKatt/rust-examples.git +[submodule "songbird"] + path = songbird + url = https://github.com/CrawKatt/songbird.git \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 251b0db..2956f1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ symphonia = { version = "0.5.4", features = ["aac", "mp3", "isomp4", "alac"] } poise = { git = "https://github.com/serenity-rs/poise.git", branch = "current" } tokio = "1.28.1" serenity = "0.12.0" -songbird = { version = "0.4.1", features = ["builtin-queue"] } +songbird = { path = "./songbird", features = ["builtin-queue"] } serde = "1.0.164" serde_json = "1.0.128" reqwest = "0.11.22" diff --git a/songbird b/songbird new file mode 160000 index 0000000..9e27f88 --- /dev/null +++ b/songbird @@ -0,0 +1 @@ +Subproject commit 9e27f88c2f57225b4cd6611bdc043ef1adb1b09d diff --git a/src/commands/ai.rs b/src/commands/ai.rs index f08462f..adeb113 100644 --- a/src/commands/ai.rs +++ b/src/commands/ai.rs @@ -23,10 +23,11 @@ pub async fn ask( let loading = ctx.say("Cargando...").await?; let url = dotenvy::var("OPENAI_API_BASE")?; let api_key = dotenvy::var("OPENAI_API_KEY")?; + let model = dotenvy::var("AI_MODEL")?; let client = Client::new_with_endpoint(url, api_key); let req = ChatCompletionRequest::new( - "meta/llama3-70b-instruct".to_string(), + model, vec![ chat_completion::ChatCompletionMessage { role: chat_completion::MessageRole::user, @@ -35,7 +36,7 @@ pub async fn ask( }, chat_completion::ChatCompletionMessage { role: chat_completion::MessageRole::system, - content: chat_completion::Content::Text(String::from("Te llamas Plantita Ayudante. Nunca superes los 2000 carácteres en tus respuestas.")), + content: chat_completion::Content::Text(String::from("Te llamas Leafy. Nunca superes los 2000 carácteres en tus respuestas.")), name: None, } ], @@ -43,19 +44,19 @@ pub async fn ask( let result = client.chat_completion(req)?; let message = result.choices[0].message.content.as_ref().into_result()?; - + let action_row = vec![CreateActionRow::Buttons(vec![ CreateButton::new("close") .style(ButtonStyle::Danger) .label("Cerrar") ]) ]; - + let reply = CreateReply::default() .content(message) .components(action_row); - + loading.edit(ctx, reply).await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/commands/audio/play.rs b/src/commands/audio/play.rs index 22a3fcf..46cb202 100644 --- a/src/commands/audio/play.rs +++ b/src/commands/audio/play.rs @@ -1,30 +1,10 @@ -use crate::handlers::error::handler; -use crate::utils::debug::IntoUnwrapResult; +use serenity::all::{CreateEmbed, CreateEmbedAuthor, CreateMessage}; +use songbird::input::YoutubeDl; +use crate::{HttpKey, location}; +use crate::commands::audio::queue::AuxMetadataKey; use crate::utils::{CommandResult, Context}; -use poise::async_trait; -use songbird::{input, Event, EventContext, EventHandler, TrackEvent}; -use std::fs; -use std::path::Path; -use std::process::Command; -use crate::utils::metadata::build_embed; - -struct FileCleaner { - paths: Vec, -} - -#[async_trait] -impl EventHandler for FileCleaner { - async fn act(&self, _: &EventContext<'_>) -> Option { - for path in &self.paths { - if Path::new(path).exists() { - fs::remove_file(path).unwrap_or_else(|why| { - println!("No se pudo eliminar el archivo: {why}"); - }); - } - } - None - } -} +use crate::utils::debug::{IntoUnwrapResult, UnwrapLog}; +use crate::handlers::error::handler; #[poise::command( prefix_command, @@ -40,92 +20,81 @@ pub async fn play( #[rest] query: String ) -> CommandResult { + let do_search = !query.starts_with("http"); let guild = ctx.guild().into_result()?.clone(); let guild_id = guild.id; super::try_join(ctx, guild).await?; - let author_name = ctx.author_member().await.into_result()?.distinct(); - let author_face = ctx.author_member().await.into_result()?.face(); - let manager = songbird::get(ctx.serenity_context()).await.into_result()?; + let author_name = ctx.author_member() + .await + .into_result()? + .distinct(); + + let author_face = ctx.author_member() + .await + .into_result()? + .face(); + + let http_client = { + let data = ctx.serenity_context().data.read().await; + data.get::() + .cloned() + .unwrap_log(location!())? + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .into_result()?; let Some(handler_lock) = manager.get(guild_id) else { ctx.say("No estás en un canal de voz").await?; return Ok(()) }; - let message = ctx.say("Descargando...").await?; - let output_path = format!("/tmp/{}.mp3", uuid::Uuid::new_v4()); - let json_path = format!("{output_path}.info.json"); - let limit_rate = "500K"; + let message = ctx.say("Buscando...").await?; - // Intentar descargar el audio, reiniciando Tor si falla - try_download_with_retry(&ctx, &query, &output_path, limit_rate).await?; - - // Si la descarga y reproducción son exitosas, continuar con la configuración de Songbird + // Necesario para bypassear el baneo de YouTube a Bots + // (No utilizar cookies de cuentas de Google personales) let mut handler = handler_lock.lock().await; - let source = input::File::new(output_path.clone()); - let track_handle = handler.enqueue_input(source.into()).await; - track_handle.add_event( - Event::Track(TrackEvent::End), - FileCleaner { paths: vec![output_path.clone(), json_path.clone()] }, - )?; - - build_embed(&ctx, &json_path, &author_name, &author_face).await?; - message.delete(ctx).await?; + let source = if do_search { + YoutubeDl::new_search(http_client, query) + } else { + YoutubeDl::new(http_client, query) + }; + let mut src: songbird::input::Input = source.into(); + + // Obtener la metadata auxiliar de la pista, como el título y la miniatura + let aux_metadata = src.aux_metadata().await?; + let title = aux_metadata.title.clone().into_result()?; + let thumbnail = aux_metadata.thumbnail.clone().into_result()?; + + // Insertar la pista en la cola de reproducción + let track = handler.enqueue_input(src).await; + + // Insertar la metadata en el TypeMap, para poder acceder + // a la metadata de la cola de reproducción + let mut map = track.typemap().write().await; + map.entry::().or_insert(aux_metadata); + + let song_name = if handler.queue().is_empty() { format!("Reproduciendo {title}") } else { format!("{title} Añadido a la cola") }; + + message.delete(ctx).await?; + let desc = format!("**Solicitado por:** {author_name}"); + let embed = CreateEmbed::new() + .title(song_name) + .author(CreateEmbedAuthor::new(author_name) + .icon_url(author_face)) + .description(desc) + .thumbnail(thumbnail) + .color(0x00ff_0000); + + let builder = CreateMessage::new().embed(embed); + ctx.channel_id().send_message(ctx.http(), builder).await?; + + // Liberar el bloqueo del manejador y del map para evitar fugas de memoria drop(handler); + drop(map); - Ok(()) -} - -/// Ejecuta el comando yt-dlp para descargar audio y devuelve su estado. -fn download_audio(query: &str, output_path: &str, limit_rate: &str) -> std::io::Result { - Command::new("yt-dlp") - .arg("-x") - .arg("--audio-format") - .arg("mp3") - .arg("--add-metadata") - .arg("--write-info-json") - .arg("--limit-rate") - .arg(limit_rate) - .arg("-o") - .arg(output_path) - .arg("--proxy") - .arg("socks5://127.0.0.1:9050") - .arg(query) - .status() -} - -/// Reinicia el servicio de Tor para permitir otra descarga. -async fn restart_tor_service(ctx: &Context<'_>) -> CommandResult { - ctx.say("Error en la descarga. Reintentando...").await?; - let restart_status = Command::new("sudo") - .arg("service") - .arg("tor") - .arg("restart") - .status(); - - if let Ok(status) = restart_status { - if status.success() { - ctx.say("Reintentando la descarga...").await?; - return Ok(()); - } - } - ctx.say("Error al reintentar la descarga").await?; - Err("Failed to restart Tor service".into()) -} - -/// Intenta descargar el audio y reinicia Tor en caso de fallo. -async fn try_download_with_retry(ctx: &Context<'_>, query: &str, output_path: &str, limit_rate: &str) -> CommandResult { - let status = download_audio(query, output_path, limit_rate)?; - - if !status.success() { - restart_tor_service(ctx).await?; - let retry_status = download_audio(query, output_path, limit_rate)?; - if !retry_status.success() { - ctx.say("Error al descargar el audio 403").await?; - return Err("Download failed after Tor restart".into()); - } - } Ok(()) } \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 28fb940..de65705 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,4 +3,5 @@ pub mod moderation; pub mod audio; pub mod info; pub mod lessons; -pub mod ai; \ No newline at end of file +pub mod ai; +pub mod translate; \ No newline at end of file diff --git a/src/commands/translate.rs b/src/commands/translate.rs new file mode 100644 index 0000000..31394c0 --- /dev/null +++ b/src/commands/translate.rs @@ -0,0 +1,73 @@ +use crate::utils::{CommandResult, Context}; +use openai_api_rs::v1::api::Client; +use openai_api_rs::v1::chat_completion; +use openai_api_rs::v1::chat_completion::ChatCompletionRequest; +use poise::CreateReply; +use serenity::all::{ButtonStyle, CreateButton}; +use serenity::builder::CreateActionRow; +use crate::utils::debug::IntoUnwrapResult; + +const SYSTEM_PROMPT: &str = + "Eres un traductor multilingüe que convierte texto de un idioma a otro. Responde únicamente\ + con la traducción exacta del texto proporcionado, utilizando el alfabeto nativo del idioma de salida.\ + No uses transliteraciones (como romanji en japonés) ni caracteres que no sean propios del idioma de salida.\ + La respuesta debe ser limpia, sin paréntesis, notas, ni comentarios adicionales. Ejemplo:\ + Texto de entrada: 'Hola, ¿cómo estás?'\ + Traducción esperada (japonés): 'こんにちは、お元気ですか?'\ + Traducción incorrecta: 'Konnichiwa, ogenki desu ka?'"; + +#[poise::command( + prefix_command, + slash_command, + guild_only, + category = "Info", + aliases("tr"), + guild_cooldown = 15, +)] +pub async fn translate( + ctx: Context<'_>, + lang: String, + #[description = "Texto a enviar al modelo de IA"] + #[rest] + prompt: String +) -> CommandResult { + let loading = ctx.say("Cargando...").await?; + let url = dotenvy::var("OPENAI_API_BASE")?; + let api_key = dotenvy::var("OPENAI_API_KEY")?; + let model = dotenvy::var("AI_MODEL")?; + let client = Client::new_with_endpoint(url, api_key); + + let req = ChatCompletionRequest::new( + model, + vec![ + chat_completion::ChatCompletionMessage { + role: chat_completion::MessageRole::system, + content: chat_completion::Content::Text(format!("{SYSTEM_PROMPT}. Idioma de salida: {lang}")), + name: None, + }, + chat_completion::ChatCompletionMessage { + role: chat_completion::MessageRole::user, + content: chat_completion::Content::Text(prompt), + name: None, + } + ], + ).max_tokens(1024); + + let result = client.chat_completion(req)?; + let message = result.choices[0].message.content.as_ref().into_result()?; + + let action_row = vec![CreateActionRow::Buttons(vec![ + CreateButton::new("close") + .style(ButtonStyle::Danger) + .label("Cerrar") + ]) + ]; + + let reply = CreateReply::default() + .content(message) + .components(action_row); + + loading.edit(ctx, reply).await?; + + Ok(()) +} diff --git a/src/utils/metadata.rs b/src/utils/metadata.rs deleted file mode 100644 index cdfd229..0000000 --- a/src/utils/metadata.rs +++ /dev/null @@ -1,44 +0,0 @@ -use serde::Deserialize; -use std::fs; -use std::path::Path; -use serenity::all::{CreateEmbed, CreateMessage}; -use serenity::builder::CreateEmbedAuthor; -use crate::utils::{CommandResult, Context}; -use crate::utils::debug::UnwrapResult; - -#[derive(Deserialize, Debug)] -pub struct VideoMetadata { - pub title: String, - pub thumbnail: Option, -} - -pub fn read_metadata>(path: P) -> UnwrapResult { - let file_content = fs::read_to_string(path)?; - let metadata: VideoMetadata = serde_json::from_str(&file_content).unwrap(); - Ok(metadata) -} - -pub async fn build_embed( - ctx: &Context<'_>, - json_path: &str, - author_name: &str, - author_face: &str -) -> CommandResult { - let metadata = read_metadata(json_path)?; - - let title = metadata.title; - let description = format!("**Solicitado por:** {author_name}"); - let thumbnail = metadata.thumbnail.unwrap_or(String::new()); - - let embed = CreateEmbed::new() - .title(title) - .author(CreateEmbedAuthor::new(author_name).icon_url(author_face)) - .description(description) - .thumbnail(thumbnail) - .color(0x00ff_0000); - - let builder = CreateMessage::new().embed(embed); - ctx.channel_id().send_message(ctx.http(), builder).await?; - - Ok(()) -} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 0760e59..70a3585 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -42,14 +42,13 @@ use crate::commands::moderation::setters::set_welcome_channel::set_welcome_chann use crate::commands::moderation::setters::set_welcome_message::set_welcome_message; use crate::commands::info::ping::ping; use crate::commands::lessons::rust::rust; +use crate::commands::translate::translate; use crate::DB; pub mod autocomplete; pub mod config; pub mod debug; pub mod embeds; -pub mod metadata; - #[allow(dead_code)] pub struct Data { pub command_descriptions: HashMap<&'static str, String> @@ -226,5 +225,6 @@ pub fn load_commands() -> Vec> { ask(), dumb(), cat_shh(), + translate(), ] }