- 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
739 lines
36 KiB
PHP
Executable File
739 lines
36 KiB
PHP
Executable File
<?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;
|
||
}
|