Files
sistema_funcionando_lastwar/discord_bot.php
nickpons666 26414094d4 Solución crítica: Reconexión automática bot Discord y sistema de health check
- Implementa heartbeat cada 30 segundos para mantener conexión WebSocket activa
- Agrega manejo de eventos de conexión/desconexión/reconexión
- Sistema de health check cada 2 minutos para verificar estado del bot
- Configuración mejorada de timeouts y reconexión automática
- Soluciona problema de bot no responde después de períodos de inactividad
2026-02-07 20:09:16 -06:00

739 lines
36 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// discord_bot.php
// Cargar configuración y dependencias
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/logger.php';
require_once __DIR__ . '/discord/DiscordSender.php';
require_once __DIR__ . '/discord/converters/HtmlToDiscordMarkdownConverter.php';
require_once __DIR__ . '/src/Translate.php';
// Importar clases necesarias
use Discord\Discord;
use Discord\WebSockets\Intents;
use Discord\WebSockets\Event;
use Discord\Parts\Channel\Message;
use Discord\Parts\Interactions\Interaction;
use Discord\Parts\WebSockets\MessageReaction;
use Discord\Builders\MessageBuilder;
use Discord\Parts\Embed\Embed;
use Discord\Parts\Guild\Member;
use Discord\Builders\Components\ActionRow;
use Discord\Builders\Components\Button;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('DiscordBot');
$logger->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,
'loop' => \React\EventLoop\Loop::get(),
'socket_options' => [
'heartbeat_interval' => 30, // Enviar heartbeat cada 30 segundos
'reconnect_timeout' => 60, // Timeout para reconexión
]
]);
// Manejar eventos de conexión y desconexión
$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 de reconexión
$discord->on('reconnected', function (Discord $discord) {
$logger->info("[RECONEXIÓN] Bot reconectado exitosamente");
});
// Evento de desconexión
$discord->on('disconnected', function (Discord $discord, $reason) {
$logger->warning("[DESCONEXIÓN] Bot desconectado. Razón: $reason");
});
// Evento de error
$discord->on('error', function ($error, Discord $discord) {
$logger->error("[ERROR DISCORD] Error en la conexión: " . $error->getMessage());
});
// 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:'));
$userId = $interaction->user->id;
$originalMessage = $interaction->message;
$channel = $interaction->channel;
try {
// Buscar el mensaje original que se está traduciendo
// Para interacciones, el mensaje original está en el mensaje de la interacción
$originalContent = '';
// Primero intentar obtener el mensaje referenciado si existe
$messageReference = $originalMessage->message_reference;
if ($messageReference && $messageReference->message_id) {
$referencedMessageId = $messageReference->message_id;
$channel->messages->fetch($referencedMessageId)->done(function (Message $referencedMessage) use ($interaction, $targetLang, $logger, $discord, $userId, $pdo) {
try {
$originalContent = trim((string) ($referencedMessage->content ?? ''));
if (empty($originalContent)) {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent('❌ No se encontró contenido para traducir.'),
true
);
return;
}
// Extraer y preservar emojis, stickers y elementos de Discord
$processedContent = preserveDiscordElements($originalContent);
$translator = new Translate(LIBRETRANSLATE_URL);
$sourceLang = $translator->detectLanguage($processedContent['text']) ?? 'es';
if ($sourceLang === $targetLang) {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent(' El mensaje ya está en este idioma.'),
true
);
return;
}
$translatedText = $translator->translateText($processedContent['text'], $sourceLang, $targetLang);
// Restaurar emojis y elementos de Discord
$finalText = restoreDiscordElements($translatedText, $processedContent['placeholders']);
// Obtener bandera
$stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1");
$stmt->execute([$targetLang]);
$flag = $stmt->fetchColumn() ?: '';
$flag = $flag !== '' ? $flag : '🏳️';
// Responder efímeramente con la traducción
$response = "{$flag} **Traducción ({$sourceLang}{$targetLang}):**\n\n" . $finalText;
$interaction->respondWithMessage(
MessageBuilder::new()->setContent($response),
true // Efímero - solo visible para el usuario
);
$logger->info("[TRADUCCIÓN EFÍMERA] Traducción {$sourceLang}{$targetLang} enviada efímeramente");
} catch (\Throwable $e) {
$logger->error("[Error Traducción Manual]", ['error' => $e->getMessage()]);
$interaction->respondWithMessage(
MessageBuilder::new()->setContent('❌ Error al procesar la traducción: ' . $e->getMessage()),
true
);
}
});
return; // Salir después de iniciar el fetch
} else {
// Fallback: usar el contenido del mensaje original si no hay referencia
$originalContent = trim((string) ($originalMessage->content ?? ''));
if (empty($originalContent)) {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent('❌ No se encontró contenido para traducir.'),
true
);
return;
}
// Extraer y preservar emojis, stickers y elementos de Discord
$processedContent = preserveDiscordElements($originalContent);
$translator = new Translate(LIBRETRANSLATE_URL);
$sourceLang = $translator->detectLanguage($processedContent['text']) ?? 'es';
if ($sourceLang === $targetLang) {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent(' El mensaje ya está en este idioma.'),
true
);
return;
}
$translatedText = $translator->translateText($processedContent['text'], $sourceLang, $targetLang);
// Restaurar emojis y elementos de Discord
$finalText = restoreDiscordElements($translatedText, $processedContent['placeholders']);
// Obtener bandera
$stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1");
$stmt->execute([$targetLang]);
$flag = $stmt->fetchColumn() ?: '';
$flag = $flag !== '' ? $flag : '🏳️';
// Responder efímeramente con la traducción
$response = "{$flag} **Traducción ({$sourceLang}{$targetLang}):**\n\n" . $finalText;
$interaction->respondWithMessage(
MessageBuilder::new()->setContent($response),
true // Efímero - solo visible para el usuario
);
$logger->info("[TRADUCCIÓN EFÍMERA] Traducción {$sourceLang}{$targetLang} enviada efímeramente");
}
} catch (\Throwable $e) {
$logger->error("[Error Interacción Manual]", ['error' => $e->getMessage()]);
$interaction->respondWithMessage(
MessageBuilder::new()->setContent('❌ Error al procesar la solicitud: ' . $e->getMessage()),
true
);
}
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(LIBRETRANSLATE_URL);
$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()
]);
}
});
// Sistema de verificación de salud periódica
$discord->on('ready', function (Discord $discord) use ($logger) {
// Programar verificación de salud cada 2 minutos
$discord->getLoop()->addPeriodicTimer(120, function() use ($discord, $logger) {
try {
// Verificar si el bot sigue conectado
if ($discord->user && $discord->user->id) {
$logger->info("[HEALTH CHECK] Bot conectado y respondiendo. Usuario: {$discord->user->username}");
} else {
$logger->warning("[HEALTH CHECK] Bot no responde correctamente");
}
} catch (\Throwable $e) {
$logger->error("[HEALTH CHECK] Error en verificación: " . $e->getMessage());
}
});
});
$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 {
$text = $message->content;
$attachments = $message->attachments;
// Verificar si el mensaje tiene contenido de texto
$hasTextContent = !empty(trim($text));
// Verificar si hay texto en los attachments (captions de imágenes/videos)
$attachmentText = '';
if (!empty($attachments)) {
foreach ($attachments as $attachment) {
// Algunos attachments tienen propiedad 'description' o 'caption'
if (!empty($attachment->description)) {
$attachmentText .= $attachment->description . ' ';
}
if (!empty($attachment->caption)) {
$attachmentText .= $attachment->caption . ' ';
}
}
}
// Combinar texto principal con texto de attachments
$fullText = trim($text . ' ' . $attachmentText);
$hasAnyText = !empty($fullText);
// Verificar si el mensaje tiene solo imágenes/adjuntos sin texto
$hasOnlyAttachments = !$hasAnyText && !empty($attachments) && count($attachments) > 0;
// Si el mensaje no tiene texto o tiene solo imágenes, no mostrar botones de traducción
if (!$hasAnyText || $hasOnlyAttachments) {
$logger->info("[TRADUCCIÓN] Mensaje #{$message->id} no tiene contenido de texto, omitiendo botones de traducción.");
return;
}
$translator = new Translate(LIBRETRANSLATE_URL);
$detectedLang = $translator->detectLanguage(strip_tags($fullText)) ?? 'es';
// Obtener idiomas activos disponibles
$langStmt = $pdo->query("SELECT language_code, language_name, flag_emoji FROM supported_languages WHERE is_active = 1");
$activeLangs = $langStmt->fetchAll(PDO::FETCH_ASSOC);
// Filtrar idiomas diferentes al detectado
$targetLangs = array_filter($activeLangs, function($lang) use ($detectedLang) {
return $lang['language_code'] !== $detectedLang;
});
if (empty($targetLangs)) {
$logger->info("[TRADUCCIÓN] No hay idiomas disponibles para traducir desde '$detectedLang'");
return;
}
// Crear botones con banderas
$components = [];
$actionRow = ActionRow::new();
$buttonCount = 0;
foreach ($targetLangs as $lang) {
$button = Button::new(Button::STYLE_SECONDARY, 'translate_manual:' . $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;
}
// Enviar mensaje con botones efímeros
$builder = MessageBuilder::new()
// ->setContent("🌍 **Selecciona idioma para traducir:**")
->setComponents($components);
$message->reply($builder, true); // true = ephemeral (solo visible para el usuario)
$logger->info("[TRADUCCIÓN] Botones de traducción enviados para mensaje #{$message->id}");
} catch (Throwable $e) {
$logger->error("[Error Traducción Discord]", ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
}
}
/**
* Función para preservar emojis, stickers y elementos de Discord durante la traducción
*/
function preserveDiscordElements($text) {
$placeholders = [];
$processedText = $text;
// Patrones para detectar elementos de Discord (excluyendo emojis Unicode)
$patterns = [
// Emojis personalizados de Discord <:name:id>
'/<a?:([a-zA-Z0-9_]+):(\d+)>/',
// Menciones de usuarios <@id> y <@!id>
'/<@!?(\d+)>/',
// Menciones de canales <#id>
'/<#(\d+)>/',
// Menciones de roles <@&id>
'/<@&(\d+)>/',
// Stickers y GIFs animados (pueden venir como URLs especiales)
'/https?:\/\/(?:media|cdn)\.discordapp\.(?:com|net)\/(stickers|attachments)\/\S+/i'
];
$index = 0;
foreach ($patterns as $pattern) {
$processedText = preg_replace_callback($pattern, function($matches) use (&$placeholders, &$index) {
$placeholder = "DISCORD_ELEMENT_{$index}";
$placeholders[$placeholder] = $matches[0];
$index++;
return $placeholder;
}, $processedText);
}
return [
'text' => $processedText,
'placeholders' => $placeholders
];
}
/**
* Función para restaurar emojis, stickers y elementos de Discord después de la traducción
*/
function restoreDiscordElements($translatedText, $placeholders) {
$restoredText = $translatedText;
foreach ($placeholders as $placeholder => $originalElement) {
$restoredText = str_replace($placeholder, $originalElement, $restoredText);
}
return $restoredText;
}