pushHandler(new StreamHandler(__DIR__.'/logs/discord_bot.log', Logger::DEBUG)); $logger->info("Iniciando bot de Discord..."); if (!defined('DISCORD_BOT_TOKEN') || empty(DISCORD_BOT_TOKEN)) { $logger->error("Error Fatal: La constante DISCORD_BOT_TOKEN no está definida o está vacía."); die(); } try { $discord = new Discord([ 'token' => DISCORD_BOT_TOKEN, 'intents' => Intents::GUILDS | Intents::GUILD_MESSAGES | Intents::DIRECT_MESSAGES | Intents::GUILD_MEMBERS | Intents::GUILD_MESSAGE_REACTIONS, 'logger' => $logger ]); $discord->on('ready', function (Discord $discord) { $discord->getLogger()->info("=================================================="); $discord->getLogger()->info("Bot conectado y listo para escuchar!"); $discord->getLogger()->info("Usuario: {$discord->user->username}#{$discord->user->discriminator}"); $discord->getLogger()->info("=================================================="); }); // Evento para nuevos miembros en el servidor $discord->on(Event::GUILD_MEMBER_ADD, function (Member $member, Discord $discord) use ($pdo, $logger) { $logger->info("[NUEVO MIEMBRO] Usuario {$member->user->username} ({$member->id}) se ha unido al servidor."); try { $stmt = $pdo->prepare( "INSERT INTO recipients (platform_id, name, type, platform, language_code) VALUES (?, ?, 'user', 'discord', 'es') ON DUPLICATE KEY UPDATE name = VALUES(name)" ); $stmt->execute([$member->id, $member->user->username]); $logger->info("[NUEVO MIEMBRO] Usuario {$member->user->username} registrado/actualizado en la base de datos."); } catch (Throwable $e) { $logger->error("[NUEVO MIEMBRO] Error al registrar al usuario.", ['error' => $e->getMessage()]); } }); // Evento para manejar interacciones (clics en botones) $discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) use ($pdo, $logger) { // DiscordPHP v7: No existe isButton(). Botones llegan como MESSAGE_COMPONENT (type = 3) y component_type = 2 $type = (int) ($interaction->type ?? 0); // 3 = MESSAGE_COMPONENT $componentType = (int) ($interaction->data->component_type ?? 0); // 2 = BUTTON if ($type !== 3 || $componentType !== 2) return; $customId = $interaction->data->custom_id; $userId = $interaction->user->id; $logger->info("[INTERACCION] Usuario $userId hizo clic en el botón: $customId"); try { if (strpos($customId, 'translate_manual:') === 0) { $targetLang = substr($customId, strlen('translate_manual:')); $originalMessage = $interaction->message; $channelId = $originalMessage->channel_id; // Responder de inmediato para evitar timeout de interacción $interaction->respondWithMessage(MessageBuilder::new()->setContent('⌛ Procesando traducción...'), true); // Extraer contenido del mensaje: primero content plano, luego embeds como fallback $originalContent = trim((string) ($originalMessage->content ?? '')); if ($originalContent === '' && count($originalMessage->embeds) > 0) { foreach ($originalMessage->embeds as $embed) { $originalContent .= trim((string) ($embed->description ?? '')) . "\n"; } $originalContent = trim($originalContent); } if (!empty($originalContent)) { try { $translator = new Translate(); $sourceLang = $translator->detectLanguage($originalContent) ?? 'es'; if ($sourceLang === $targetLang) { // Fallback: muchas plantillas están en ES; intenta forzar ES como origen $fallbackSrc = 'es'; if ($fallbackSrc !== $targetLang) { $translatedText = $translator->translateText($originalContent, $fallbackSrc, $targetLang); $sourceLang = $fallbackSrc; } else { $translatedText = null; } } else { $translatedText = $translator->translateText($originalContent, $sourceLang, $targetLang); } // Obtener bandera desde supported_languages $flag = ''; try { $stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1"); $stmt->execute([$targetLang]); $flag = $stmt->fetchColumn() ?: ''; } catch (\Throwable $e) { /* noop */ } $flag = $flag !== '' ? $flag : '🏳️'; // Enviar resultado como mensaje normal al canal, mencionando al usuario $sender = new DiscordSender(DISCORD_BOT_TOKEN); $mention = '<@' . $userId . '>'; $content = "{$mention} {$flag} Traducción ({$sourceLang} → {$targetLang}):\n> " . $translatedText; // Pequeña espera para que el mensaje efímero aparezca primero usleep(300000); $sender->sendRawMessage($channelId, $content); if (empty($translatedText)) { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($channelId, '<@' . $userId . '> El mensaje ya está en este idioma.'); } } catch (\Throwable $e) { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($channelId, '<@' . $userId . "> Error al traducir: " . $e->getMessage()); } } else { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($channelId, '<@' . $userId . '> No se encontró contenido para traducir.'); } return; // Salir después de manejar la interacción } // Traducción de plantillas completas (comandos #) if (strpos($customId, 'translate_template:') === 0) { $payload = substr($customId, strlen('translate_template:')); $parts = explode(':', $payload, 2); if (count($parts) !== 2) { $interaction->respondWithMessage(MessageBuilder::new()->setContent('Formato de botón inválido.'), true); return; } [$commandKey, $targetLang] = $parts; $interaction->respondWithMessage(MessageBuilder::new()->setContent('⌛ Traduciendo plantilla...'), true); try { // Obtener contenido HTML original de la plantilla $stmt = $pdo->prepare("SELECT message_content FROM recurrent_messages WHERE telegram_command = ?"); $stmt->execute([$commandKey]); $template = $stmt->fetch(PDO::FETCH_ASSOC); if (!$template) { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> No se encontró la plantilla.'); return; } $originalHtml = $template['message_content'] ?? ''; $converter = new HtmlToDiscordMarkdownConverter(); $partsArr = $converter->convertToArray($originalHtml); // Unir todos los segmentos de texto (las imágenes no se traducen) $fullText = ''; foreach ($partsArr as $p) { if (($p['type'] ?? '') === 'text') { $fullText .= ($fullText === '' ? '' : "\n\n") . trim((string)$p['content']); } } if ($fullText === '') { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> No hay contenido de texto para traducir en la plantilla.'); return; } $translator = new Translate(); $sourceLang = $translator->detectLanguage($fullText) ?? 'es'; if ($sourceLang === $targetLang) { // Fallback: fuerza ES como origen si coincide con el destino (común en nuestras plantillas) $fallbackSrc = 'es'; if ($fallbackSrc !== $targetLang) { $translated = $translator->translateText($fullText, $fallbackSrc, $targetLang); $sourceLang = $fallbackSrc; } else { $translated = ''; } } else { $translated = $translator->translateText($fullText, $sourceLang, $targetLang); } if ($translated === '') { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> El contenido ya está en este idioma.'); return; } // Bandera $flag = ''; try { $stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1"); $stmt->execute([$targetLang]); $flag = $stmt->fetchColumn() ?: ''; } catch (\Throwable $e) { /* noop */ } $flag = $flag !== '' ? $flag : '🏳️'; $sender = new DiscordSender(DISCORD_BOT_TOKEN); // Pequeña espera para que el mensaje efímero aparezca primero usleep(300000); $sender->sendRawMessage($interaction->channel_id, '<@' . $userId . "> {$flag} Traducción plantilla ({$sourceLang} → {$targetLang}):\n" . $translated); } catch (\Throwable $e) { $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sender->sendRawMessage($interaction->channel_id, '<@' . $userId . "> Error al traducir plantilla: " . $e->getMessage()); } return; } if ($customId === 'platicar_bot' || $customId === 'usar_ia') { $newMode = ($customId === 'platicar_bot') ? 'bot' : 'ia'; $stmt = $pdo->prepare("UPDATE recipients SET chat_mode = ? WHERE platform_id = ? AND platform = 'discord'"); $stmt->execute([$newMode, $userId]); $responseText = $newMode === 'bot' ? "🤖 Modo cambiado a 'Platicar con bot'. Ahora puedes usar los comandos normales como `/comandos`." : "🧠 Modo cambiado a 'Usar IA'. Todo lo que escribas será procesado por la IA.\n\nEscribe `/agente` para volver a este menú."; $interaction->respondWithMessage(MessageBuilder::new()->setContent($responseText), true); $logger->info("[MODO AGENTE] Usuario $userId cambió al modo: $newMode"); } } catch (Throwable $e) { $logger->error("[INTERACCION] Error al procesar el botón.", ['error' => $e->getMessage()]); $interaction->respondWithMessage(MessageBuilder::new()->setContent("Hubo un error al cambiar de modo."), true); } }); // Evento para manejar mensajes $discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) use ($pdo, $logger) { if ($message->author->bot) return; $logger->info("[Mensaje Recibido] En canal '{$message->channel->name}' de @{$message->author->username}: {$message->content}"); $isPrivateChat = $message->channel->is_private; $userId = $message->author->id; $content = $message->content; try { if ($isPrivateChat) { // --- LÓGICA DE CHAT PRIVADO (AGENTE/IA/BOT) --- $logger->info("[MODO AGENTE] Mensaje en chat privado de $userId."); $stmt = $pdo->prepare("SELECT chat_mode FROM recipients WHERE platform_id = ? AND platform = 'discord'"); $stmt->execute([$userId]); $userChatMode = $stmt->fetchColumn(); if ($userChatMode === false) { // --- Nuevo Usuario en DM --- $logger->info("[NUEVO USUARIO DM] Usuario $userId no encontrado. Registrando..."); $userName = $message->author->username; $insertStmt = $pdo->prepare( "INSERT INTO recipients (platform_id, name, type, platform, language_code, chat_mode) VALUES (?, ?, 'user', 'discord', 'es', 'agent') ON DUPLICATE KEY UPDATE name = VALUES(name)" ); $insertStmt->execute([$userId, $userName]); $logger->info("[NUEVO USUARIO DM] Usuario $userId ($userName) registrado con modo 'agent'."); $userChatMode = 'agent'; // Forzar modo agente para la primera interacción } if (trim($content) === '/agente') { $stmt = $pdo->prepare("UPDATE recipients SET chat_mode = 'agent' WHERE platform_id = ? AND platform = 'discord'"); $stmt->execute([$userId]); $userChatMode = 'agent'; $logger->info("[MODO AGENTE] Usuario $userId usó /agente. Reseteando a modo 'agent'."); } switch ($userChatMode) { case 'agent': $builder = MessageBuilder::new()->setContent("👋 Hola! ¿Cómo quieres interactuar?"); $actionRow = ActionRow::new() ->addComponent(Button::new(Button::STYLE_PRIMARY, 'platicar_bot')->setLabel('🤖 Platicar con bot')) ->addComponent(Button::new(Button::STYLE_SUCCESS, 'usar_ia')->setLabel('🧠 Usar IA')); $builder->addComponent($actionRow); $message->channel->sendMessage($builder); return; case 'ia': $logger->info("[MODO IA] Mensaje de $userId para la IA: $content"); $n8nWebhookUrl = $_ENV['N8N_IA_WEBHOOK_URL_DISCORD'] ?? null; if ($n8nWebhookUrl) { $postData = [ 'chat_id' => $message->channel_id, 'user_id' => $userId, 'message' => $content, 'name' => $message->author->username ]; $ch = curl_init($n8nWebhookUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_exec($ch); curl_close($ch); $logger->info("[MODO IA] Mensaje reenviado a n8n."); } else { $logger->error("[MODO IA] La variable N8N_IA_WEBHOOK_URL no está configurada."); } return; case 'bot': // Continuar con la lógica normal de bot abajo break; } } // --- LÓGICA DE BOT NORMAL (CHATS PÚBLICOS O MODO 'bot') --- if (strpos($content, '/') === 0 || strpos($content, '#') === 0) { handleDiscordCommand($message, $pdo, $logger); } else if (strtolower($content) === '!ping') { $message->reply('pong!'); } else { handleDiscordTranslation($message, $pdo, $logger); } } catch (Throwable $e) { $logger->error("!!!!!!!!!! ERROR FATAL CAPTURADO !!!!!!!!!!", [ 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString() ]); } }); $discord->run(); } catch (Throwable $e) { $logger->critical("!!!!!!!!!! ERROR FATAL AL INICIAR !!!!!!!!!!", [ 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ]); die(); } function handleDiscordCommand(Message $message, PDO $pdo, Logger $logger) { $text = trim($message->content); $userId = $message->author->id; if (strpos($text, '#') === 0) { $command = ltrim($text, '#'); $logger->info("[Comando] Usuario @{$message->author->username} solicitó: #{$command}"); try { $stmt = $pdo->prepare("SELECT message_content FROM recurrent_messages WHERE telegram_command = ?"); $stmt->execute([$command]); $template = $stmt->fetch(PDO::FETCH_ASSOC); if ($template) { $logger->info("[Comando] Plantilla encontrada para #{$command}."); $originalHtml = $template['message_content']; $sender = new DiscordSender(DISCORD_BOT_TOKEN); $sentMessageIds = $sender->sendMessage($message->channel_id, $originalHtml); if ($sentMessageIds && !empty($sentMessageIds)) { $firstMessageId = $sentMessageIds[0]; $logger->info("Mensajes enviados, primer ID: {$firstMessageId}"); // Añadir botones de traducción dinámicos $message->channel->messages->fetch($firstMessageId)->done(function (Message $sentMessage) use ($pdo, $logger, $command) { $langStmt = $pdo->query("SELECT language_code, language_name, flag_emoji FROM supported_languages WHERE is_active = 1"); $activeLangs = $langStmt->fetchAll(PDO::FETCH_ASSOC); if (count($activeLangs) > 1) { $components = []; $actionRow = ActionRow::new(); $buttonCount = 0; foreach ($activeLangs as $lang) { // Para plantillas, incluir el comando en el custom_id para traducir el contenido completo original $button = Button::new(Button::STYLE_SECONDARY, 'translate_template:' . $command . ':' . $lang['language_code']) ->setLabel($lang['language_name']); if (!empty($lang['flag_emoji'])) { $button->setEmoji($lang['flag_emoji']); } $actionRow->addComponent($button); $buttonCount++; // Discord permite máximo 5 botones por ActionRow if ($buttonCount % 5 === 0) { $components[] = $actionRow; $actionRow = ActionRow::new(); } } if ($buttonCount % 5 !== 0) { $components[] = $actionRow; } $builder = MessageBuilder::new()->setComponents($components); $sentMessage->edit($builder); $logger->info("Botones de traducción añadidos al mensaje {$sentMessage->id}"); } }); } } else { $message->reply("El comando `#{$command}` no fue encontrado."); } } catch (Throwable $e) { $logger->error("[Error Comando] Procesando #{$command}", ['error' => $e->getMessage()]); $message->reply("Ocurrió un error inesperado al procesar tu comando."); } } elseif (strpos($text, '/setlang') === 0) { $parts = explode(' ', $text, 2); if (count($parts) < 2 || strlen(trim($parts[1])) !== 2) { $message->reply("❌ Formato incorrecto. Usa: `/setlang es`"); return; } $newLangCode = strtolower(trim($parts[1])); $stmt = $pdo->prepare("UPDATE recipients SET language_code = ? WHERE platform_id = ? AND platform = 'discord'"); $stmt->execute([$newLangCode, $userId]); $message->reply("✅ Tu idioma ha sido establecido a '" . strtoupper($newLangCode) . "'."); } elseif (strpos($text, '/bienvenida') === 0) { $configStmt = $pdo->query("SELECT * FROM telegram_bot_messages WHERE id = 1"); $welcomeConfig = $configStmt->fetch(PDO::FETCH_ASSOC); if ($welcomeConfig && $welcomeConfig['is_active']) { $messageText = str_replace('{user_name}', $message->author->username, $welcomeConfig['message_text']); $converter = new HtmlToDiscordMarkdownConverter(); $markdownText = $converter->convert($messageText); $builder = MessageBuilder::new()->setContent($markdownText); $actionRow = ActionRow::new() ->addComponent(Button::new(Button::STYLE_LINK, $welcomeConfig['group_invite_link'])->setLabel($welcomeConfig['button_text'])); $builder->addComponent($actionRow); $message->channel->sendMessage($builder); } else { $message->reply("ℹ️ La función de bienvenida no está activa."); } } elseif (strpos($text, '/comandos') === 0) { $stmt = $pdo->query("SELECT telegram_command, name FROM recurrent_messages WHERE telegram_command IS NOT NULL AND telegram_command != '' ORDER BY name ASC"); $commands = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($commands)) { $message->reply("ℹ️ No hay comandos personalizados disponibles."); } else { $response = "**LISTA DE COMANDOS DISPONIBLES**\n\n"; foreach ($commands as $cmd) { $command = trim($cmd['telegram_command']); if (strpos($command, '#') !== 0) $command = '#' . $command; $name = trim($cmd['name']); $response .= "`" . $command . "` - " . $name . "\n"; } $response .= "\nℹ️ Escribe el comando para usarlo."; $message->channel->sendMessage($response); } } elseif (strpos($text, '/agente') === 0) { $prompt = trim(substr($text, strlen('/agente'))); $logger->info("[Comando] Usuario @{$message->author->username} solicitó: /agente con prompt: '{$prompt}'"); if (empty($prompt)) { $message->reply("Por favor, escribe tu consulta después de /agente."); return; } $n8nWebhookUrl = $_ENV['N8N_IA_WEBHOOK_URL_DISCORD'] ?? null; if ($n8nWebhookUrl) { $postData = [ 'chat_id' => $message->channel_id, 'user_id' => $message->author->id, 'message' => $prompt, 'name' => $message->author->username ]; $ch = curl_init($n8nWebhookUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_exec($ch); curl_close($ch); $logger->info("[Comando /agente] Prompt reenviado a n8n."); $message->reply("Tu solicitud ha sido enviada al agente. Recibirás una respuesta en breve."); } else { $logger->error("[Comando /agente] La variable N8N_IA_WEBHOOK_URL no está configurada."); $message->reply("No se pudo procesar tu solicitud. El servicio de agente no está configurado."); } } } function handleDiscordTranslation(Message $message, PDO $pdo, Logger $logger) { try { $translator = new Translate(); $text = $message->content; $detectedLang = $translator->detectLanguage(strip_tags($text)) ?? 'es'; // Obtener idiomas activos y encolar traducciones $langStmt = $pdo->query("SELECT language_code FROM supported_languages WHERE is_active = 1"); $activeLangs = $langStmt->fetchAll(PDO::FETCH_COLUMN); if (in_array($detectedLang, $activeLangs)) { foreach ($activeLangs as $targetLang) { if ($detectedLang === $targetLang) { continue; // No traducir al mismo idioma } $sql = "INSERT INTO translation_queue (platform, message_id, chat_id, user_id, text_to_translate, source_lang, target_lang) VALUES (?, ?, ?, ?, ?, ?, ?)"; $stmt = $pdo->prepare($sql); $stmt->execute([ 'discord', $message->id, $message->channel_id, $message->author->id, $text, $detectedLang, $targetLang ]); $logger->info("[QUEUE] Mensaje de Discord #{$message->id} encolado para traducción de '$detectedLang' a '$targetLang'."); } } } catch (Throwable $e) { $logger->error("[Error Encolando Traducción Discord]", ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); } }