Commit inicial con archivos existentes

This commit is contained in:
2026-01-17 16:14:00 -06:00
parent 48671dc88e
commit 4c48c279de
2539 changed files with 2412708 additions and 0 deletions

374
src/CommandLocker.php Executable file
View File

@@ -0,0 +1,374 @@
<?php
class CommandLocker {
private $pdo;
private $lockTimeout = 300; // 5 minutos de tiempo de espera para el bloqueo
private $debug = true;
private $dbConnection;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
// Configurar PDO para que lance excepciones
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// Inicializar la conexión a la base de datos
$this->getConnection();
}
/**
* Obtiene una conexión activa a la base de datos
*
* @return PDO La conexión a la base de datos
*/
private function getConnection() {
try {
// Verificar si la conexión actual es válida
if ($this->pdo === null || !$this->isConnectionAlive()) {
$this->log("Estableciendo nueva conexión a la base de datos");
$this->pdo = $this->createNewConnection();
}
return $this->pdo;
} catch (Exception $e) {
$this->log("Error al obtener conexión a la base de datos", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Verifica si la conexión actual está activa
*
* @return bool True si la conexión está activa, false en caso contrario
*/
private function isConnectionAlive() {
try {
$this->pdo->query('SELECT 1');
return true;
} catch (Exception $e) {
$this->log("La conexión a la base de datos no está activa", [
'error' => $e->getMessage()
]);
return false;
}
}
/**
* Crea una nueva conexión a la base de datos
*
* @return PDO La nueva conexión
*/
private function createNewConnection() {
// Obtener la configuración de la base de datos del archivo de configuración
require_once __DIR__ . '/../includes/db.php';
// Obtener la instancia de la conexión del archivo db.php
global $pdo;
if (!($pdo instanceof PDO)) {
throw new Exception("No se pudo establecer una conexión a la base de datos");
}
return $pdo;
}
private function log($message, $data = []) {
if ($this->debug) {
$logMessage = date('[Y-m-d H:i:s] ') . $message;
if (!empty($data)) {
$logMessage .= ' ' . json_encode($data);
}
error_log($logMessage);
}
}
/**
* Intenta adquirir un bloqueo para un comando
*
* @param string $command El comando a bloquear
* @param int $chatId El ID del chat donde se ejecutó el comando
* @param string $type Tipo de bloqueo ('command' o 'translation')
* @param array $data Datos adicionales a almacenar con el bloqueo
* @return array|false Retorna el ID del bloqueo si se adquiere, false si ya existe un bloqueo activo
*/
public function acquireLock($command, $chatId, $type = 'command', $data = []) {
$this->log("Intentando adquirir bloqueo", [
'command' => $command,
'chatId' => $chatId,
'type' => $type,
'data' => $data
]);
$this->cleanupExpiredLocks();
try {
$this->pdo->beginTransaction();
// Buscar cualquier bloqueo existente para este chat y comando (sin filtrar por estado)
$query = "
SELECT id, status, expires_at, data, created_at
FROM command_locks
WHERE command = ?
AND chat_id = ?
ORDER BY created_at DESC
LIMIT 1 FOR UPDATE
";
$stmt = $this->pdo->prepare($query);
$this->log("Ejecutando consulta de bloqueo existente", [
'query' => $stmt->queryString,
'params' => [$command, $chatId, $type]
]);
$stmt->execute([$command, $chatId]);
$existingLock = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existingLock) {
$this->log("Bloqueo existente encontrado", $existingLock);
// Si hay un bloqueo activo y reciente, bloquear nueva ejecución
if ($existingLock['status'] === 'processing' && strtotime($existingLock['created_at']) > strtotime('-5 minutes')) {
$this->log("Bloqueo ya en proceso, rechazando nueva solicitud");
$this->pdo->rollBack();
return false; // Ya hay un bloqueo activo
}
// Reutilizar el mismo registro para evitar violar la única (chat_id,command)
$expiresAt = (new DateTime('+5 minutes'))->format('Y-m-d H:i:s');
$dataJson = !empty($data) ? json_encode($data) : null;
$upd = $this->pdo->prepare("UPDATE command_locks SET type = ?, status='processing', data = COALESCE(?, data), message_id = NULL, expires_at = ?, updated_at = NOW() WHERE id = ?");
$upd->execute([$type, $dataJson, $expiresAt, $existingLock['id']]);
$this->pdo->commit();
$this->log("Bloqueo reutilizado y pasado a processing", ['lockId' => $existingLock['id']]);
return (int)$existingLock['id'];
} else {
$this->log("No se encontraron bloqueos existentes");
}
// Insertar un nuevo bloqueo
$expiresAt = (new DateTime('+5 minutes'))->format('Y-m-d H:i:s');
$dataJson = !empty($data) ? json_encode($data) : null;
$query = "
INSERT INTO command_locks
(chat_id, command, type, data, status, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'processing', ?, NOW(), NOW())
";
$stmt = $this->pdo->prepare($query);
$this->log("Insertando nuevo bloqueo", [
'chat_id' => $chatId,
'command' => $command,
'type' => $type,
'has_data' => !empty($data),
'expires_at' => $expiresAt
]);
$stmt->execute([$chatId, $command, $type, $dataJson, $expiresAt]);
$lockId = $this->pdo->lastInsertId();
$this->pdo->commit();
$this->log("Bloqueo adquirido exitosamente", ['lockId' => $lockId]);
return (int)$lockId;
} catch (Exception $e) {
$this->log("Error al adquirir bloqueo", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
try {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
} catch (Exception $rollbackException) {
$this->log("Error al hacer rollback", [
'error' => $rollbackException->getMessage()
]);
}
return false;
}
}
/**
* Actualiza el estado de un bloqueo
*
* @param int $lockId ID del bloqueo a actualizar
* @param string $status Nuevo estado ('processing', 'completed', 'failed')
* @param string|null $messageId ID del mensaje asociado (opcional)
* @return bool True si se actualizó correctamente, false en caso contrario
*/
public function updateLockStatus($lockId, $status, $messageId = null) {
$this->log("Actualizando estado de bloqueo", [
'lockId' => $lockId,
'status' => $status,
'messageId' => $messageId
]);
try {
// Asegurarse de que tenemos una conexión válida
$pdo = $this->getConnection();
$query = "
UPDATE command_locks
SET status = ?,
message_id = COALESCE(?, message_id),
expires_at = CASE WHEN ? = 'completed' THEN DATE_ADD(NOW(), INTERVAL 1 MINUTE) ELSE expires_at END,
updated_at = NOW()
WHERE id = ?
";
$stmt = $pdo->prepare($query);
$result = $stmt->execute([$status, $messageId, $status, $lockId]);
$rowCount = $stmt->rowCount();
$this->log("Resultado de actualización de estado", [
'lockId' => $lockId,
'status' => $status,
'rowCount' => $rowCount,
'result' => $result
]);
// Si no se actualizó ninguna fila, verificar si el bloqueo existe
if ($rowCount === 0) {
$checkStmt = $pdo->prepare("SELECT id FROM command_locks WHERE id = ?");
$checkStmt->execute([$lockId]);
$exists = $checkStmt->fetch(PDO::FETCH_ASSOC);
$this->log("Verificación de existencia de bloqueo", [
'lockId' => $lockId,
'exists' => (bool)$exists
]);
if (!$exists) {
$this->log("Error: El bloqueo no existe", ['lockId' => $lockId]);
return false;
}
}
return $result && $rowCount > 0;
} catch (Exception $e) {
$this->log("Error al actualizar estado de bloqueo", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
/**
* Libera un bloqueo marcándolo como completado
*
* @param int $lockId ID del bloqueo a liberar
* @param string|null $messageId ID del mensaje asociado (opcional)
* @return bool True si se actualizó correctamente, false en caso contrario
*/
public function releaseLock($lockId, $messageId = null) {
$this->log("Liberando bloqueo", [
'lockId' => $lockId,
'messageId' => $messageId
]);
$result = $this->updateLockStatus($lockId, 'completed', $messageId);
$this->log("Resultado de liberación de bloqueo", [
'lockId' => $lockId,
'success' => $result
]);
return $result;
}
/**
* Marca un bloqueo como fallido
*
* @param int $lockId ID del bloqueo a marcar como fallido
* @param string $errorMessage Mensaje de error (opcional)
* @return bool True si se actualizó correctamente, false en caso contrario
*/
public function failLock($lockId, $errorMessage = '') {
$this->log("Marcando bloqueo como fallido", [
'lockId' => $lockId,
'errorMessage' => $errorMessage
]);
try {
// Asegurarse de que tenemos una conexión válida
$pdo = $this->getConnection();
$query = "
UPDATE command_locks
SET status = 'failed',
data = JSON_MERGE_PATCH(COALESCE(data, '{}'), ?),
expires_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE),
updated_at = NOW()
WHERE id = ?
";
$stmt = $pdo->prepare($query);
$result = $stmt->execute([json_encode(['error' => $errorMessage, 'failed_at' => date('Y-m-d H:i:s')]), $lockId]);
$rowCount = $stmt->rowCount();
$this->log("Resultado de marcar bloqueo como fallido", [
'lockId' => $lockId,
'rowCount' => $rowCount,
'result' => $result
]);
return $result && $rowCount > 0;
} catch (Exception $e) {
$this->log("Error al marcar bloqueo como fallido", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
/**
* Limpia los bloqueos expirados
*/
private function cleanupExpiredLocks() {
try {
$this->pdo->exec("
DELETE FROM command_locks
WHERE (status = 'completed' AND expires_at <= NOW())
OR (status = 'processing' AND created_at < DATE_SUB(NOW(), INTERVAL 1 HOUR))
");
} catch (Exception $e) {
error_log("Error al limpiar bloqueos expirados: " . $e->getMessage());
}
}
/**
* Verifica si un comando está siendo procesado actualmente
*
* @param string $command Comando a verificar
* @param int $chatId ID del chat
* @param string $type Tipo de bloqueo ('command' o 'translation')
* @return bool True si el comando está siendo procesado, false en caso contrario
*/
public function isCommandProcessing($command, $chatId, $type = 'command') {
$stmt = $this->pdo->prepare("
SELECT COUNT(*)
FROM command_locks
WHERE command = ?
AND chat_id = ?
AND type = ?
AND status = 'processing'
AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
");
$stmt->execute([$command, $chatId, $type]);
return (int)$stmt->fetchColumn() > 0;
}
}

16
src/DiscordSender.php Executable file
View File

@@ -0,0 +1,16 @@
<?php
/**
* ARCHIVO BRIDGE - Migración Progresiva
*
* Este archivo mantiene compatibilidad con código existente
* mientras redirige a la nueva ubicación de la clase.
*
* Nueva ubicación: /discord/DiscordSender.php
* Fecha de migración: 2025-11-25
*/
// Cargar el archivo desde la nueva ubicación
require_once __DIR__ . '/../discord/DiscordSender.php';
// Este archivo puede ser eliminado cuando toda la migración esté completa
// y todas las referencias apunten directamente a discord/DiscordSender.php

View File

@@ -0,0 +1,16 @@
<?php
/**
* ARCHIVO BRIDGE - Migración Progresiva
*
* Este archivo mantiene compatibilidad con código existente
* mientras redirige a la nueva ubicación de la clase.
*
* Nueva ubicación: /discord/converters/HtmlToDiscordMarkdownConverter.php
* Fecha de migración: 2025-11-25
*/
// Cargar el archivo desde la nueva ubicación
require_once __DIR__ . '/../discord/converters/HtmlToDiscordMarkdownConverter.php';
// Este archivo puede ser eliminado cuando toda la migración esté completa
// y todas las referencias apunten directamente a discord/converters/HtmlToDiscordMarkdownConverter.php

View File

@@ -0,0 +1,16 @@
<?php
/**
* ARCHIVO BRIDGE - Migración Progresiva
*
* Este archivo mantiene compatibilidad con código existente
* mientras redirige a la nueva ubicación de la clase.
*
* Nueva ubicación: /telegram/converters/HtmlToTelegramHtmlConverter.php
* Fecha de migración: 2025-11-25
*/
// Cargar el archivo desde la nueva ubicación
require_once __DIR__ . '/../telegram/converters/HtmlToTelegramHtmlConverter.php';
// Este archivo puede ser eliminado cuando toda la migración esté completa
// y todas las referencias apunten directamente a telegram/converters/HtmlToTelegramHtmlConverter.php

16
src/TelegramSender.php Executable file
View File

@@ -0,0 +1,16 @@
<?php
/**
* ARCHIVO BRIDGE - Migración Progresiva
*
* Este archivo mantiene compatibilidad con código existente
* mientras redirige a la nueva ubicación de la clase.
*
* Nueva ubicación: /telegram/TelegramSender.php
* Fecha de migración: 2025-11-25
*/
// Cargar el archivo desde la nueva ubicación
require_once __DIR__ . '/../telegram/TelegramSender.php';
// Este archivo puede ser eliminado cuando toda la migración esté completa
// y todas las referencias apunten directamente a telegram/TelegramSender.php

172
src/Translate.php Executable file
View File

@@ -0,0 +1,172 @@
<?php
class Translate
{
private $apiUrl;
public function __construct()
{
$this->apiUrl = rtrim($_ENV['LIBRETRANSLATE_URL'], '/');
}
public function detectLanguage($text)
{
if (empty(trim($text))) {
return null;
}
$response = $this->request('/detect', ['q' => $text]);
if (isset($response[0]['language'])) {
return $response[0]['language'];
}
return null;
}
public function translateText($text, $source, $target)
{
if (empty(trim($text))) {
return null;
}
$response = $this->request('/translate', [
'q' => $text,
'source' => $source,
'target' => $target,
'format' => 'text'
]);
return $response['translatedText'] ?? null;
}
public function translateHtml($html, $source, $target)
{
if (empty(trim($html))) {
return null;
}
$maxLength = 4000; // Límite de caracteres para LibreTranslate
$translatedHtml = '';
custom_log("translateHtml: Original HTML length: " . mb_strlen($html));
// Dividir el HTML en bloques para evitar exceder el límite de LibreTranslate
$chunks = $this->splitHtmlIntoChunks($html, $maxLength);
custom_log("translateHtml: Number of chunks: " . count($chunks));
foreach ($chunks as $index => $chunk) {
custom_log("translateHtml: Processing chunk " . ($index + 1) . "/" . count($chunks) . ", length: " . mb_strlen($chunk));
$response = $this->request('/translate', [
'q' => $chunk,
'source' => $source,
'target' => $target,
'format' => 'html'
]);
$translatedChunk = $response['translatedText'] ?? null;
if ($translatedChunk) {
$translatedHtml .= $translatedChunk;
custom_log("translateHtml: Chunk " . ($index + 1) . " translated successfully.");
} else {
// Si una parte falla, devolver la parte original para no perder contenido
$translatedHtml .= $chunk;
custom_log("translateHtml: Chunk " . ($index + 1) . " failed to translate. Appending original chunk.");
}
}
return $translatedHtml;
}
private function splitHtmlIntoChunks($html, $maxLength)
{
$chunks = [];
$currentChunk = '';
$dom = new DOMDocument();
// Suprimir errores de HTML mal formado
@$dom->loadHTML('<div>' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$xpath = new DOMXPath($dom);
// Query all direct children of the body (or the implied div)
$nodes = $xpath->query('//body/*');
if ($nodes->length === 0) {
// If no block-level elements, treat the whole HTML as one chunk
$nodes = $xpath->query('//body/text()');
if ($nodes->length === 0) {
// Fallback if no text nodes either, just return the original HTML as one chunk
return [$html];
}
}
foreach ($nodes as $node) {
$nodeHtml = $dom->saveHTML($node);
custom_log("splitHtmlIntoChunks: Processing node, length: " . mb_strlen($nodeHtml) . ", HTML: " . substr($nodeHtml, 0, 100) . "...");
if (mb_strlen($currentChunk . $nodeHtml) <= $maxLength) {
$currentChunk .= $nodeHtml;
} else {
if (!empty($currentChunk)) {
$chunks[] = $currentChunk;
custom_log("splitHtmlIntoChunks: Added chunk, length: " . mb_strlen($currentChunk));
}
$currentChunk = $nodeHtml;
}
}
if (!empty($currentChunk)) {
$chunks[] = $currentChunk;
custom_log("splitHtmlIntoChunks: Added final chunk, length: " . mb_strlen($currentChunk));
}
return $chunks;
}
public function translateTextBatch($texts, $source, $target)
{
if (empty($texts)) {
return null;
}
$response = $this->request('/translate', [
'q' => $texts,
'source' => $source,
'target' => $target,
'format' => 'text'
]);
return $response['translatedText'] ?? null;
}
public function getSupportedLanguages()
{
$response = $this->request('/languages', [], 'GET');
return $response ?? [];
}
private function request($endpoint, $data, $method = 'POST')
{
$url = $this->apiUrl . $endpoint;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Considera la seguridad de esto
if ($method === 'POST') {
custom_log("LibreTranslate POST Request: URL=" . $url . " | Data=" . json_encode($data));
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
} else {
custom_log("LibreTranslate GET Request: URL=" . $url);
// Para GET, los datos (si los hubiera) se añadirían a la URL, pero /languages no requiere
}
$response_body = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
custom_log("LibreTranslate Response: HTTP Code=" . $http_code . " | Body=" . $response_body);
if ($http_code >= 400) {
return null; // Devolver null si hay un error de cliente o servidor
}
return json_decode($response_body, true);
}
}

223
src/TranslationCache.php Executable file
View File

@@ -0,0 +1,223 @@
<?php
// Cargar logger si está disponible
if (file_exists(__DIR__ . '/../includes/logger.php')) {
require_once __DIR__ . '/../includes/logger.php';
}
// Función fallback si custom_log no está definida
if (!function_exists('custom_log')) {
function custom_log($message) {
error_log($message);
}
}
class TranslationCache {
private $redis;
private $ttl = 604800; // 7 días por defecto
private $enabled = true;
private $prefix = 'trans:';
public function __construct() {
// Verificar si el caché está habilitado
$this->enabled = ($_ENV['TRANSLATION_CACHE_ENABLED'] ?? 'true') === 'true';
if (!$this->enabled) {
custom_log("[CACHE] Caché de traducciones deshabilitado");
return;
}
// Configurar TTL desde environment
if (isset($_ENV['TRANSLATION_CACHE_TTL'])) {
$this->ttl = (int)$_ENV['TRANSLATION_CACHE_TTL'];
}
try {
// Intentar conectar a Redis
$this->redis = new Redis();
$host = $_ENV['REDIS_HOST'] ?? 'localhost';
$port = (int)($_ENV['REDIS_PORT'] ?? 6379);
$password = $_ENV['REDIS_PASSWORD'] ?? null;
$connected = $this->redis->connect($host, $port, 2.0); // 2 segundos timeout
if (!$connected) {
throw new Exception("No se pudo conectar a Redis en {$host}:{$port}");
}
// Autenticar si hay password
if ($password) {
$this->redis->auth($password);
}
// Verificar conexión
$this->redis->ping();
custom_log("[CACHE] Conectado a Redis en {$host}:{$port}");
} catch (Exception $e) {
custom_log("[CACHE] Error al conectar a Redis: " . $e->getMessage());
custom_log("[CACHE] El sistema funcionará sin caché");
$this->enabled = false;
$this->redis = null;
}
}
/**
* Obtener traducción del caché
*/
public function get($text, $source, $target) {
if (!$this->enabled || !$this->redis) {
return false;
}
try {
$key = $this->generateKey($text, $source, $target);
$cached = $this->redis->get($key);
if ($cached !== false) {
$this->incrementHits();
custom_log("[CACHE] HIT: {$source}->{$target} (key: " . substr($key, 0, 20) . "...)");
return $cached;
}
$this->incrementMisses();
custom_log("[CACHE] MISS: {$source}->{$target}");
return false;
} catch (Exception $e) {
custom_log("[CACHE] Error al obtener del caché: " . $e->getMessage());
return false;
}
}
/**
* Guardar traducción en el caché
*/
public function set($text, $source, $target, $translation) {
if (!$this->enabled || !$this->redis || empty($translation)) {
return false;
}
try {
$key = $this->generateKey($text, $source, $target);
$result = $this->redis->setex($key, $this->ttl, $translation);
if ($result) {
custom_log("[CACHE] Guardado: {$source}->{$target} (TTL: {$this->ttl}s)");
}
return $result;
} catch (Exception $e) {
custom_log("[CACHE] Error al guardar en caché: " . $e->getMessage());
return false;
}
}
/**
* Generar clave única para la traducción
*/
private function generateKey($text, $source, $target) {
// Normalizar texto para mejor hit rate
$normalized = trim(strtolower($text));
$hash = md5($normalized . '|' . $source . '|' . $target);
return $this->prefix . $hash;
}
/**
* Incrementar contador de hits
*/
private function incrementHits() {
if (!$this->enabled || !$this->redis) {
return;
}
try {
$this->redis->incr($this->prefix . 'stats:hits');
} catch (Exception $e) {
// Ignorar errores en estadísticas
}
}
/**
* Incrementar contador de misses
*/
private function incrementMisses() {
if (!$this->enabled || !$this->redis) {
return;
}
try {
$this->redis->incr($this->prefix . 'stats:misses');
} catch (Exception $e) {
// Ignorar errores en estadísticas
}
}
/**
* Obtener estadísticas del caché
*/
public function getStats() {
if (!$this->enabled || !$this->redis) {
return [
'enabled' => false,
'hits' => 0,
'misses' => 0,
'hit_rate' => 0
];
}
try {
$hits = (int)$this->redis->get($this->prefix . 'stats:hits') ?: 0;
$misses = (int)$this->redis->get($this->prefix . 'stats:misses') ?: 0;
$total = $hits + $misses;
$hitRate = $total > 0 ? round(($hits / $total) * 100, 2) : 0;
return [
'enabled' => true,
'hits' => $hits,
'misses' => $misses,
'total_requests' => $total,
'hit_rate' => $hitRate
];
} catch (Exception $e) {
custom_log("[CACHE] Error al obtener estadísticas: " . $e->getMessage());
return [
'enabled' => true,
'error' => $e->getMessage()
];
}
}
/**
* Limpiar todo el caché de traducciones
*/
public function clear() {
if (!$this->enabled || !$this->redis) {
return false;
}
try {
$keys = $this->redis->keys($this->prefix . '*');
if (!empty($keys)) {
$deleted = $this->redis->del($keys);
custom_log("[CACHE] Limpiado: {$deleted} claves eliminadas");
return $deleted;
}
return 0;
} catch (Exception $e) {
custom_log("[CACHE] Error al limpiar caché: " . $e->getMessage());
return false;
}
}
/**
* Verificar si el caché está habilitado y funcionando
*/
public function isEnabled() {
return $this->enabled && $this->redis !== null;
}
}

236
src/TranslationWorker.php Executable file
View File

@@ -0,0 +1,236 @@
<?php
class TranslationWorker {
private $workerId;
private $pdo;
private $translator;
private $running = true;
private $sleepTime = 2; // segundos entre iteraciones
public function __construct($workerId, $pdo) {
$this->workerId = "worker_" . $workerId . "_" . getmypid();
$this->pdo = $pdo;
$this->translator = new Translate();
// Configurar sleep time desde environment
if (isset($_ENV['TRANSLATION_WORKER_SLEEP'])) {
$this->sleepTime = (int)$_ENV['TRANSLATION_WORKER_SLEEP'];
}
custom_log("[{$this->workerId}] Inicializado");
}
public function run() {
// Configurar manejadores de señales
pcntl_async_signals(true);
pcntl_signal(SIGINT, [$this, 'handleShutdown']);
pcntl_signal(SIGTERM, [$this, 'handleShutdown']);
custom_log("[{$this->workerId}] Iniciando loop de procesamiento");
while ($this->running) {
$job = null;
try {
// Obtener trabajo de la cola
$job = $this->getNextJob();
if ($job) {
$this->processJob($job);
} else {
// No hay trabajos, esperar
sleep($this->sleepTime);
}
// Procesar señales
pcntl_signal_dispatch();
} catch (Exception $e) {
custom_log("[{$this->workerId}] Error: " . $e->getMessage());
if ($job) {
$this->markJobAsFailed($job['id'], $e->getMessage());
}
sleep($this->sleepTime);
}
}
custom_log("[{$this->workerId}] Detenido");
}
private function getNextJob() {
try {
// Verificar y reconectar si es necesario
try {
$this->pdo->query('SELECT 1');
} catch (PDOException $e) {
custom_log("[{$this->workerId}] Reconectando a BD...");
// La conexión se perdió, será recreada por el pool
throw $e;
}
// Iniciar transacción
$this->pdo->beginTransaction();
// Obtener trabajo con bloqueo (FOR UPDATE SKIP LOCKED)
$stmt = $this->pdo->prepare("
SELECT * FROM translation_queue
WHERE status = 'pending'
AND attempts < 5
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
");
$stmt->execute();
$job = $stmt->fetch(PDO::FETCH_ASSOC);
if ($job) {
// Marcar como procesando
$updateStmt = $this->pdo->prepare("
UPDATE translation_queue
SET status = 'processing',
attempts = attempts + 1,
worker_id = ?,
processing_started_at = NOW()
WHERE id = ?
");
$updateStmt->execute([$this->workerId, $job['id']]);
custom_log("[{$this->workerId}] Job #{$job['id']} obtenido (intento #{$job['attempts']})");
}
// Confirmar transacción
$this->pdo->commit();
return $job;
} catch (Exception $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
private function processJob($job) {
custom_log("[{$this->workerId}] Procesando job #{$job['id']}: {$job['source_lang']}->{$job['target_lang']}");
try {
// Traducir texto
$translatedText = $this->translator->translateHtml(
$job['text_to_translate'],
$job['source_lang'],
$job['target_lang']
);
// Verificar que la traducción sea válida
if ($translatedText && trim(strtolower($translatedText)) !== trim(strtolower($job['text_to_translate']))) {
// Enviar traducción
$success = $this->sendTranslation($job, $translatedText);
if ($success) {
$this->markJobAsCompleted($job['id']);
custom_log("[{$this->workerId}] Job #{$job['id']} completado");
} else {
throw new Exception("Fallo al enviar traducción");
}
} else {
// Traducción vacía o idéntica
$this->markJobAsCompleted($job['id'], 'Traducción vacía o idéntica al original');
custom_log("[{$this->workerId}] Job #{$job['id']} completado (sin envío)");
}
} catch (Exception $e) {
throw $e;
}
}
private function sendTranslation($job, $translatedText) {
if ($job['platform'] === 'discord') {
return $this->sendToDiscord($job, $translatedText);
} else {
return $this->sendToTelegram($job, $translatedText);
}
}
private function sendToDiscord($job, $translatedText) {
require_once __DIR__ . '/DiscordSender.php';
$discordSender = new DiscordSender($_ENV['DISCORD_BOT_TOKEN']);
// Obtener bandera del idioma
$flag = $this->getLanguageFlag($job['target_lang']);
$replyContent = "*{$flag} Traducción:*\n> {$translatedText}";
$response = $discordSender->sendRawMessage($job['chat_id'], $replyContent);
return $response !== null;
}
private function sendToTelegram($job, $translatedText) {
require_once __DIR__ . '/TelegramSender.php';
$telegram = new TelegramSender($_ENV['TELEGRAM_BOT_TOKEN'], $this->pdo);
$response = $telegram->sendMessage($job['chat_id'], $translatedText, [
'parse_mode' => 'HTML',
'reply_to_message_id' => $job['message_id']
], false, $job['target_lang']);
return $response !== null;
}
private function getLanguageFlag($langCode) {
try {
$stmt = $this->pdo->prepare("
SELECT flag_emoji
FROM supported_languages
WHERE language_code = ? AND is_active = 1
");
$stmt->execute([$langCode]);
$flag = $stmt->fetchColumn();
if ($flag) {
return $flag;
}
} catch (Exception $e) {
// Ignorar error
}
// Fallback flags
$fallbackFlags = [
'es' => '🇲🇽', 'en' => '🇺🇸', 'pt' => '🇵🇹',
'fr' => '🇫🇷', 'de' => '🇩🇪', 'it' => '🇮🇹'
];
return $fallbackFlags[strtolower($langCode)] ?? '🏳️';
}
private function markJobAsCompleted($jobId, $message = null) {
$stmt = $this->pdo->prepare("
UPDATE translation_queue
SET status = 'completed',
error_message = ?,
processed_at = NOW()
WHERE id = ?
");
$stmt->execute([$message, $jobId]);
}
private function markJobAsFailed($jobId, $errorMessage) {
$stmt = $this->pdo->prepare("
UPDATE translation_queue
SET status = 'failed',
error_message = ?
WHERE id = ?
");
$stmt->execute([$errorMessage, $jobId]);
custom_log("[{$this->workerId}] Job #{$jobId} marcado como fallido: {$errorMessage}");
}
public function handleShutdown($signal) {
custom_log("[{$this->workerId}] Señal {$signal} recibida, deteniendo...");
$this->running = false;
}
}

187
src/TranslationWorkerPool.php Executable file
View File

@@ -0,0 +1,187 @@
<?php
class TranslationWorkerPool {
private $workers = [];
private $maxWorkers = 4;
private $pdo;
private $environment;
private $running = true;
public function __construct($pdo, $maxWorkers = null) {
$this->pdo = $pdo;
// Configurar número de workers desde environment
if ($maxWorkers !== null) {
$this->maxWorkers = $maxWorkers;
} elseif (isset($_ENV['TRANSLATION_WORKERS'])) {
$this->maxWorkers = (int)$_ENV['TRANSLATION_WORKERS'];
}
// Capturar el environment del proceso padre
$this->environment = getenv('APP_ENVIRONMENT') ?: 'pruebas';
custom_log("[WORKER_POOL] Inicializando con {$this->maxWorkers} workers");
custom_log("[WORKER_POOL] Environment: {$this->environment}");
}
public function start() {
// Configurar manejadores de señales para el pool
pcntl_async_signals(true);
pcntl_signal(SIGINT, [$this, 'handleShutdown']);
pcntl_signal(SIGTERM, [$this, 'handleShutdown']);
custom_log("[WORKER_POOL] Iniciando {$this->maxWorkers} workers...");
// Iniciar workers
for ($i = 0; $i < $this->maxWorkers; $i++) {
$this->spawnWorker($i);
}
custom_log("[WORKER_POOL] Todos los workers iniciados");
// Supervisar workers
$this->supervise();
}
private function spawnWorker($id) {
$pid = pcntl_fork();
if ($pid == -1) {
// Error al hacer fork
custom_log("[WORKER_POOL] ERROR: No se pudo crear worker {$id}");
return false;
} elseif ($pid == 0) {
// Proceso hijo - ejecutar worker
try {
// Heredar environment del padre
putenv('APP_ENVIRONMENT=' . $this->environment);
custom_log("[WORKER_{$id}] Iniciado con PID " . getmypid() . ", APP_ENVIRONMENT={$this->environment}");
// IMPORTANTE: Crear nueva conexión PDO para este worker
// No usar la conexión del padre porque se cierra por inactividad
$maxRetries = 3;
$retryDelay = 2;
$pdo = null;
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
require_once __DIR__ . '/../includes/db.php';
// $pdo se crea en db.php
if (isset($pdo) && $pdo !== null) {
custom_log("[WORKER_{$id}] Conexión a BD establecida");
break;
}
} catch (Exception $dbError) {
custom_log("[WORKER_{$id}] Intento {$attempt}/{$maxRetries} de conexión a BD falló: " . $dbError->getMessage());
if ($attempt < $maxRetries) {
sleep($retryDelay);
} else {
throw new Exception("No se pudo conectar a la BD después de {$maxRetries} intentos");
}
}
}
// Cargar dependencias necesarias
require_once __DIR__ . '/Translate.php';
require_once __DIR__ . '/TranslationWorker.php';
// Crear y ejecutar worker con su propia conexión
$worker = new TranslationWorker($id, $pdo);
$worker->run();
} catch (Exception $e) {
custom_log("[WORKER_{$id}] ERROR FATAL: " . $e->getMessage());
custom_log("[WORKER_{$id}] Stack trace: " . $e->getTraceAsString());
}
exit(0);
} else {
// Proceso padre - registrar worker
$this->workers[$id] = [
'pid' => $pid,
'started' => time(),
'restarts' => 0
];
custom_log("[WORKER_POOL] Worker {$id} spawned con PID {$pid}");
return true;
}
}
private function supervise() {
custom_log("[WORKER_POOL] Iniciando supervisión de workers");
while ($this->running) {
// Verificar estado de cada worker
foreach ($this->workers as $id => $info) {
$status = pcntl_waitpid($info['pid'], $exitStatus, WNOHANG);
if ($status > 0) {
// Worker terminó
$exitCode = pcntl_wexitstatus($exitStatus);
custom_log("[WORKER_POOL] Worker {$id} (PID {$info['pid']}) terminó con código {$exitCode}");
// Reiniciar worker si el pool sigue corriendo
if ($this->running) {
custom_log("[WORKER_POOL] Reiniciando worker {$id}...");
$this->workers[$id]['restarts']++;
// Esperar un poco antes de reiniciar
sleep(1);
$this->spawnWorker($id);
}
} elseif ($status < 0) {
// Error al verificar estado
custom_log("[WORKER_POOL] Error al verificar worker {$id}");
}
}
// Procesar señales
pcntl_signal_dispatch();
// Esperar antes de la siguiente verificación
sleep(5);
}
custom_log("[WORKER_POOL] Supervisión detenida");
}
public function handleShutdown($signal) {
custom_log("[WORKER_POOL] Señal {$signal} recibida, deteniendo pool...");
$this->running = false;
// Enviar señal de terminación a todos los workers
foreach ($this->workers as $id => $info) {
custom_log("[WORKER_POOL] Enviando SIGTERM a worker {$id} (PID {$info['pid']})");
posix_kill($info['pid'], SIGTERM);
}
// Esperar a que todos los workers terminen
custom_log("[WORKER_POOL] Esperando a que los workers terminen...");
foreach ($this->workers as $id => $info) {
pcntl_waitpid($info['pid'], $status);
custom_log("[WORKER_POOL] Worker {$id} terminado");
}
custom_log("[WORKER_POOL] Pool detenido completamente");
}
public function getStats() {
$stats = [
'total_workers' => $this->maxWorkers,
'workers' => []
];
foreach ($this->workers as $id => $info) {
$stats['workers'][$id] = [
'pid' => $info['pid'],
'uptime' => time() - $info['started'],
'restarts' => $info['restarts']
];
}
return $stats;
}
}