Files
sistema_funcionando_lastwar/discord_bot.php

536 lines
27 KiB
PHP
Executable File
Raw 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__ . '/includes/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
]);
$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()]);
}
}