This commit is contained in:
Lizandro Guarnizo
2026-01-27 23:56:49 -05:00
parent 56c6a24fa5
commit 37d6bf1e20
43 changed files with 9668 additions and 318 deletions
+328
View File
@@ -0,0 +1,328 @@
<?php
/**
* Gestor de estados de conversación en Redis
* Mantiene estados temporales de chat para flujos conversacionales
*/
namespace WhatsApp\Queue;
use Predis\Client as RedisClient;
class ConversationState {
private $redis;
private $prefix = 'chat:state:';
private $defaultTTL = 3600; // 1 hora
public function __construct(RedisClient $redis = null) {
$this->redis = $redis ?? new RedisClient([
'scheme' => getenv('REDIS_SCHEME') ?: 'tcp',
'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
'port' => getenv('REDIS_PORT') ?: 6379,
'password' => getenv('REDIS_PASSWORD') ?: null,
'database' => getenv('REDIS_DB') ?: 0,
]);
}
/**
* Establecer estado de conversación
*
* @param string $userId ID del usuario
* @param string $state Estado actual (ej: 'waiting_name', 'selecting_option', 'uploading_document')
* @param array $context Datos adicionales del contexto
* @param int $ttl Tiempo de vida en segundos
* @return bool
*/
public function setState(string $userId, string $state, array $context = [], int $ttl = null): bool {
try {
$key = $this->prefix . $userId;
$ttl = $ttl ?? $this->defaultTTL;
$data = [
'state' => $state,
'context' => $context,
'updated_at' => time()
];
$this->redis->setex($key, $ttl, json_encode($data));
return true;
} catch (\Exception $e) {
error_log("Failed to set conversation state: " . $e->getMessage());
return false;
}
}
/**
* Obtener estado actual de conversación
*
* @param string $userId
* @return array|null ['state' => string, 'context' => array, 'updated_at' => int]
*/
public function getState(string $userId): ?array {
try {
$key = $this->prefix . $userId;
$data = $this->redis->get($key);
if (!$data) {
return null;
}
return json_decode($data, true);
} catch (\Exception $e) {
error_log("Failed to get conversation state: " . $e->getMessage());
return null;
}
}
/**
* Actualizar contexto sin cambiar el estado
*
* @param string $userId
* @param array $context Datos a agregar/actualizar en el contexto
* @return bool
*/
public function updateContext(string $userId, array $context): bool {
try {
$current = $this->getState($userId);
if (!$current) {
return false;
}
$current['context'] = array_merge($current['context'] ?? [], $context);
$current['updated_at'] = time();
$key = $this->prefix . $userId;
$ttl = $this->redis->ttl($key);
// Mantener el TTL original
if ($ttl > 0) {
$this->redis->setex($key, $ttl, json_encode($current));
} else {
$this->redis->setex($key, $this->defaultTTL, json_encode($current));
}
return true;
} catch (\Exception $e) {
error_log("Failed to update conversation context: " . $e->getMessage());
return false;
}
}
/**
* Limpiar estado (finalizar conversación)
*
* @param string $userId
* @return bool
*/
public function clearState(string $userId): bool {
try {
$key = $this->prefix . $userId;
$this->redis->del([$key]);
return true;
} catch (\Exception $e) {
error_log("Failed to clear conversation state: " . $e->getMessage());
return false;
}
}
/**
* Extender TTL del estado actual
*
* @param string $userId
* @param int $additionalSeconds Segundos adicionales
* @return bool
*/
public function extendTTL(string $userId, int $additionalSeconds): bool {
try {
$key = $this->prefix . $userId;
$currentTTL = $this->redis->ttl($key);
if ($currentTTL > 0) {
$this->redis->expire($key, $currentTTL + $additionalSeconds);
return true;
}
return false;
} catch (\Exception $e) {
error_log("Failed to extend conversation TTL: " . $e->getMessage());
return false;
}
}
/**
* Verificar si usuario está en un estado específico
*
* @param string $userId
* @param string $expectedState
* @return bool
*/
public function isInState(string $userId, string $expectedState): bool {
$current = $this->getState($userId);
return $current && $current['state'] === $expectedState;
}
/**
* Obtener valor específico del contexto
*
* @param string $userId
* @param string $key
* @param mixed $default
* @return mixed
*/
public function getContextValue(string $userId, string $key, $default = null) {
$state = $this->getState($userId);
if (!$state || !isset($state['context'][$key])) {
return $default;
}
return $state['context'][$key];
}
/**
* Almacenar datos temporales (para flujos multi-paso)
*
* Ejemplo: Durante un proceso de registro que requiere nombre, email, teléfono
* se van guardando los datos hasta completar el formulario
*
* @param string $userId
* @param string $key
* @param mixed $value
* @return bool
*/
public function setTemporaryData(string $userId, string $key, $value): bool {
try {
$tempKey = $this->prefix . 'temp:' . $userId . ':' . $key;
$this->redis->setex($tempKey, 1800, json_encode($value)); // 30 minutos
return true;
} catch (\Exception $e) {
error_log("Failed to set temporary data: " . $e->getMessage());
return false;
}
}
/**
* Obtener datos temporales
*
* @param string $userId
* @param string $key
* @return mixed|null
*/
public function getTemporaryData(string $userId, string $key) {
try {
$tempKey = $this->prefix . 'temp:' . $userId . ':' . $key;
$data = $this->redis->get($tempKey);
if (!$data) {
return null;
}
return json_decode($data, true);
} catch (\Exception $e) {
error_log("Failed to get temporary data: " . $e->getMessage());
return null;
}
}
/**
* Limpiar datos temporales
*
* @param string $userId
* @param string $key Si no se especifica, limpia todos los datos temp del usuario
* @return bool
*/
public function clearTemporaryData(string $userId, string $key = null): bool {
try {
if ($key) {
$tempKey = $this->prefix . 'temp:' . $userId . ':' . $key;
$this->redis->del([$tempKey]);
} else {
// Limpiar todos los datos temp del usuario
$pattern = $this->prefix . 'temp:' . $userId . ':*';
$keys = $this->redis->keys($pattern);
if (!empty($keys)) {
$this->redis->del($keys);
}
}
return true;
} catch (\Exception $e) {
error_log("Failed to clear temporary data: " . $e->getMessage());
return false;
}
}
/**
* Marcar usuario como "escribiendo..." (útil para UX)
*
* @param string $userId
* @param int $ttl Segundos (típicamente 3-5 segundos)
* @return bool
*/
public function setTyping(string $userId, int $ttl = 5): bool {
try {
$key = $this->prefix . 'typing:' . $userId;
$this->redis->setex($key, $ttl, '1');
return true;
} catch (\Exception $e) {
error_log("Failed to set typing indicator: " . $e->getMessage());
return false;
}
}
/**
* Verificar si usuario está escribiendo
*
* @param string $userId
* @return bool
*/
public function isTyping(string $userId): bool {
try {
$key = $this->prefix . 'typing:' . $userId;
return (bool) $this->redis->exists($key);
} catch (\Exception $e) {
return false;
}
}
/**
* Obtener estadísticas de estados activos
*
* @return array
*/
public function getActiveStates(): array {
try {
$pattern = $this->prefix . '*';
$keys = $this->redis->keys($pattern);
$states = [];
foreach ($keys as $key) {
// Excluir keys temporales y typing
if (strpos($key, ':temp:') !== false || strpos($key, ':typing:') !== false) {
continue;
}
$data = $this->redis->get($key);
if ($data) {
$decoded = json_decode($data, true);
$state = $decoded['state'] ?? 'unknown';
if (!isset($states[$state])) {
$states[$state] = 0;
}
$states[$state]++;
}
}
return $states;
} catch (\Exception $e) {
error_log("Failed to get active states: " . $e->getMessage());
return [];
}
}
}
+360
View File
@@ -0,0 +1,360 @@
<?php
/**
* Gestor de colas con Redis para procesamiento asíncrono
* Implementa pattern Producer-Consumer para mensajes de WhatsApp
*/
namespace WhatsApp\Queue;
use Predis\Client as RedisClient;
use Monolog\Logger;
class RedisQueue {
private $redis;
private $logger;
private $queuePrefix = 'whatsapp:queue:';
// Límites de rate limiting para WhatsApp Business API
const RATE_LIMIT_WINDOW = 1; // segundos
const MAX_MESSAGES_PER_SECOND = 80;
const RATE_LIMIT_KEY = 'whatsapp:ratelimit:';
public function __construct(RedisClient $redis = null, Logger $logger = null) {
// Conectar a Redis con configuración desde .env
$this->redis = $redis ?? new RedisClient([
'scheme' => getenv('REDIS_SCHEME') ?: 'tcp',
'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
'port' => getenv('REDIS_PORT') ?: 6379,
'password' => getenv('REDIS_PASSWORD') ?: null,
'database' => getenv('REDIS_DB') ?: 0,
]);
$this->logger = $logger;
}
/**
* Agregar mensaje a la cola para procesamiento asíncrono
*
* @param string $queueName Nombre de la cola (ej: 'messages', 'media', 'notifications')
* @param array $data Datos del mensaje a procesar
* @param int $priority Prioridad (0=alta, 1=normal, 2=baja)
* @return bool
*/
public function push(string $queueName, array $data, int $priority = 1): bool {
try {
$payload = json_encode([
'data' => $data,
'priority' => $priority,
'queued_at' => time(),
'attempts' => 0
]);
// Usar LPUSH para agregar al inicio (FIFO con BRPOP)
$key = $this->queuePrefix . $queueName;
$result = $this->redis->lpush($key, [$payload]);
if ($this->logger) {
$this->logger->info("Message pushed to queue", [
'queue' => $queueName,
'priority' => $priority,
'data_keys' => array_keys($data)
]);
}
return $result > 0;
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->error("Failed to push to queue", [
'queue' => $queueName,
'error' => $e->getMessage()
]);
}
return false;
}
}
/**
* Obtener mensaje de la cola (bloqueante)
*
* @param string|array $queueName Nombre de cola(s) a escuchar
* @param int $timeout Timeout en segundos (0 = infinito)
* @return array|null [queue_name, payload] o null si timeout
*/
public function pop($queueName, int $timeout = 5): ?array {
try {
$queues = is_array($queueName) ? $queueName : [$queueName];
$keys = array_map(fn($q) => $this->queuePrefix . $q, $queues);
// BRPOP espera hasta que haya un elemento o timeout
$result = $this->redis->brpop($keys, $timeout);
if (!$result) {
return null;
}
// $result = [queue_key, payload_json]
$queueKey = $result[0];
$payload = json_decode($result[1], true);
// Extraer nombre de cola sin prefijo
$queue = str_replace($this->queuePrefix, '', $queueKey);
if ($this->logger) {
$this->logger->debug("Message popped from queue", [
'queue' => $queue,
'attempts' => $payload['attempts'] ?? 0
]);
}
return [
'queue' => $queue,
'data' => $payload['data'] ?? [],
'priority' => $payload['priority'] ?? 1,
'queued_at' => $payload['queued_at'] ?? time(),
'attempts' => $payload['attempts'] ?? 0
];
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->error("Failed to pop from queue", [
'queue' => $queueName,
'error' => $e->getMessage()
]);
}
return null;
}
}
/**
* Re-encolar mensaje fallido con backoff exponencial
*
* @param string $queueName
* @param array $message Mensaje original con metadata
* @param int $maxAttempts Intentos máximos antes de mover a DLQ
* @return bool
*/
public function retry(string $queueName, array $message, int $maxAttempts = 3): bool {
$attempts = ($message['attempts'] ?? 0) + 1;
if ($attempts >= $maxAttempts) {
// Mover a Dead Letter Queue
return $this->moveToDLQ($queueName, $message);
}
// Incrementar contador de intentos
$message['attempts'] = $attempts;
$message['last_attempt'] = time();
// Backoff exponencial: 2^attempts segundos
$delay = pow(2, $attempts);
try {
$payload = json_encode($message);
$delayedKey = $this->queuePrefix . $queueName . ':delayed';
// Usar ZADD con score = timestamp futuro
$executeAt = time() + $delay;
$this->redis->zadd($delayedKey, [$payload => $executeAt]);
if ($this->logger) {
$this->logger->warning("Message scheduled for retry", [
'queue' => $queueName,
'attempt' => $attempts,
'delay' => $delay,
'execute_at' => date('Y-m-d H:i:s', $executeAt)
]);
}
return true;
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->error("Failed to schedule retry", [
'queue' => $queueName,
'error' => $e->getMessage()
]);
}
return false;
}
}
/**
* Mover mensaje a Dead Letter Queue (mensajes que fallaron definitivamente)
*/
private function moveToDLQ(string $queueName, array $message): bool {
try {
$dlqKey = $this->queuePrefix . 'dlq:' . $queueName;
$payload = json_encode([
'original_message' => $message,
'failed_at' => time(),
'attempts' => $message['attempts'] ?? 0
]);
$this->redis->lpush($dlqKey, [$payload]);
if ($this->logger) {
$this->logger->error("Message moved to DLQ", [
'queue' => $queueName,
'attempts' => $message['attempts'] ?? 0
]);
}
return true;
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->critical("Failed to move to DLQ", [
'queue' => $queueName,
'error' => $e->getMessage()
]);
}
return false;
}
}
/**
* Procesar mensajes delayed (ejecutar cron cada minuto)
*/
public function processDelayed(string $queueName): int {
try {
$delayedKey = $this->queuePrefix . $queueName . ':delayed';
$now = time();
// Obtener mensajes cuyo score (timestamp) <= ahora
$messages = $this->redis->zrangebyscore($delayedKey, '-inf', $now);
$processed = 0;
foreach ($messages as $payload) {
$message = json_decode($payload, true);
// Mover de delayed a queue normal
if ($this->push($queueName, $message['data'], $message['priority'] ?? 1)) {
$this->redis->zrem($delayedKey, $payload);
$processed++;
}
}
if ($processed > 0 && $this->logger) {
$this->logger->info("Processed delayed messages", [
'queue' => $queueName,
'count' => $processed
]);
}
return $processed;
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->error("Failed to process delayed", [
'queue' => $queueName,
'error' => $e->getMessage()
]);
}
return 0;
}
}
/**
* Verificar rate limit para WhatsApp API
*
* @param string $identifier Identificador único (ej: phone_number_id)
* @return bool true si puede enviar, false si excede límite
*/
public function checkRateLimit(string $identifier): bool {
try {
$key = self::RATE_LIMIT_KEY . $identifier;
$count = $this->redis->incr($key);
// Establecer expiración solo en el primer incremento
if ($count == 1) {
$this->redis->expire($key, self::RATE_LIMIT_WINDOW);
}
if ($count > self::MAX_MESSAGES_PER_SECOND) {
if ($this->logger) {
$this->logger->warning("Rate limit exceeded", [
'identifier' => $identifier,
'count' => $count,
'limit' => self::MAX_MESSAGES_PER_SECOND
]);
}
return false;
}
return true;
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->error("Rate limit check failed", [
'error' => $e->getMessage()
]);
}
// En caso de error, permitir para no bloquear
return true;
}
}
/**
* Obtener estadísticas de las colas
*/
public function getStats(array $queueNames = ['messages', 'media', 'notifications']): array {
$stats = [];
foreach ($queueNames as $queueName) {
$key = $this->queuePrefix . $queueName;
$delayedKey = $key . ':delayed';
$dlqKey = $this->queuePrefix . 'dlq:' . $queueName;
$stats[$queueName] = [
'pending' => $this->redis->llen($key),
'delayed' => $this->redis->zcard($delayedKey),
'failed' => $this->redis->llen($dlqKey)
];
}
return $stats;
}
/**
* Limpiar colas (para testing/desarrollo)
*/
public function flush(string $queueName = null): bool {
try {
if ($queueName) {
$keys = [
$this->queuePrefix . $queueName,
$this->queuePrefix . $queueName . ':delayed',
$this->queuePrefix . 'dlq:' . $queueName
];
foreach ($keys as $key) {
$this->redis->del([$key]);
}
} else {
// Flush todas las colas con el prefijo
$pattern = $this->queuePrefix . '*';
$keys = $this->redis->keys($pattern);
if (!empty($keys)) {
$this->redis->del($keys);
}
}
if ($this->logger) {
$this->logger->info("Queue flushed", ['queue' => $queueName ?? 'all']);
}
return true;
} catch (\Exception $e) {
if ($this->logger) {
$this->logger->error("Failed to flush queue", [
'queue' => $queueName,
'error' => $e->getMessage()
]);
}
return false;
}
}
/**
* Cerrar conexión Redis
*/
public function disconnect(): void {
if ($this->redis) {
$this->redis->disconnect();
}
}
}