queue = new RedisQueue(); // Verificar si rate limiting está habilitado en .env $this->rateLimitEnabled = getenv('ENABLE_RATE_LIMIT') !== 'false'; } /** * Sobrescribir sendMessage con rate limiting */ public function sendTextMessage($to, $message, $meta = null) { if (!$this->rateLimitEnabled) { return parent::sendTextMessage($to, $message, $meta); } // Verificar límite antes de enviar if (!$this->checkRateLimit('messages')) { // Si excede límite, encolar para envío posterior $this->queueMessage('text', [ 'to' => $to, 'message' => $message, 'meta' => $meta ]); return [ 'success' => true, 'queued' => true, 'message' => 'Mensaje encolado por rate limit' ]; } // Registrar envío $this->recordSend('messages'); // Enviar normalmente return parent::sendTextMessage($to, $message, $meta); } /** * Sobrescribir sendTemplateMessage con rate limiting */ public function sendTemplateMessage( $to, $templateName, $language = 'es', $bodyParameters = [], $headerParameters = [], $rawComponents = null, $meta = null, $dryRun = false ) { if (!$this->rateLimitEnabled || $dryRun) { return parent::sendTemplateMessage( $to, $templateName, $language, $bodyParameters, $headerParameters, $rawComponents, $meta, $dryRun ); } // Verificar límite if (!$this->checkRateLimit('templates')) { // Encolar template $this->queueMessage('template', [ 'to' => $to, 'templateName' => $templateName, 'language' => $language, 'bodyParameters' => $bodyParameters, 'headerParameters' => $headerParameters, 'rawComponents' => $rawComponents, 'meta' => $meta ]); return [ 'success' => true, 'queued' => true, 'message' => 'Template encolada por rate limit' ]; } // Registrar envío $this->recordSend('templates'); // Enviar normalmente return parent::sendTemplateMessage( $to, $templateName, $language, $bodyParameters, $headerParameters, $rawComponents, $meta, $dryRun ); } /** * Verificar si se puede enviar según rate limit */ private function checkRateLimit(string $type): bool { try { $redis = $this->queue->redis ?? new Predis\Client([ 'scheme' => getenv('REDIS_SCHEME') ?: 'tcp', 'host' => getenv('REDIS_HOST') ?: '127.0.0.1', 'port' => getenv('REDIS_PORT') ?: 6379, ]); $now = time(); $phoneNumberId = $this->getPhoneNumberId(); // Verificar límite por segundo $keySecond = "whatsapp:ratelimit:second:{$phoneNumberId}:{$now}"; $countSecond = $redis->incr($keySecond); if ($countSecond == 1) { $redis->expire($keySecond, 1); } if ($countSecond > self::RATE_LIMIT_PER_SECOND) { error_log("Rate limit exceeded: second limit ($countSecond/" . self::RATE_LIMIT_PER_SECOND . ")"); return false; } // Verificar límite por hora $hourKey = date('Y-m-d-H'); $keyHour = "whatsapp:ratelimit:hour:{$phoneNumberId}:{$hourKey}"; $countHour = $redis->incr($keyHour); if ($countHour == 1) { $redis->expire($keyHour, 3600); } if ($countHour > self::RATE_LIMIT_PER_HOUR) { error_log("Rate limit exceeded: hour limit ($countHour/" . self::RATE_LIMIT_PER_HOUR . ")"); return false; } // Verificar límite por día $dayKey = date('Y-m-d'); $keyDay = "whatsapp:ratelimit:day:{$phoneNumberId}:{$dayKey}"; $countDay = $redis->incr($keyDay); if ($countDay == 1) { $redis->expire($keyDay, 86400); } if ($countDay > self::RATE_LIMIT_PER_DAY) { error_log("Rate limit exceeded: day limit ($countDay/" . self::RATE_LIMIT_PER_DAY . ")"); return false; } return true; } catch (Exception $e) { error_log("Rate limit check failed: " . $e->getMessage()); // En caso de error, permitir envío para no bloquear return true; } } /** * Registrar envío realizado (incrementar contadores) */ private function recordSend(string $type): void { // Los contadores ya se incrementaron en checkRateLimit // Esta función existe por si se necesita logging adicional if (function_exists('writeLog')) { writeLog('DEBUG', "Message sent with rate limit check", [ 'type' => $type, 'timestamp' => date('Y-m-d H:i:s') ]); } } /** * Encolar mensaje que excedió rate limit */ private function queueMessage(string $type, array $data): bool { try { // Encolar con prioridad normal para envío diferido return $this->queue->push('outgoing_messages', [ 'type' => $type, 'data' => $data, 'queued_at' => time() ], 1); } catch (Exception $e) { error_log("Failed to queue rate-limited message: " . $e->getMessage()); return false; } } /** * Obtener estadísticas de rate limit */ public function getRateLimitStats(): array { try { $redis = new Predis\Client([ 'scheme' => getenv('REDIS_SCHEME') ?: 'tcp', 'host' => getenv('REDIS_HOST') ?: '127.0.0.1', 'port' => getenv('REDIS_PORT') ?: 6379, ]); $phoneNumberId = $this->getPhoneNumberId(); $now = time(); $hourKey = date('Y-m-d-H'); $dayKey = date('Y-m-d'); $keySecond = "whatsapp:ratelimit:second:{$phoneNumberId}:{$now}"; $keyHour = "whatsapp:ratelimit:hour:{$phoneNumberId}:{$hourKey}"; $keyDay = "whatsapp:ratelimit:day:{$phoneNumberId}:{$dayKey}"; return [ 'per_second' => [ 'current' => (int) $redis->get($keySecond) ?: 0, 'limit' => self::RATE_LIMIT_PER_SECOND, 'remaining' => max(0, self::RATE_LIMIT_PER_SECOND - ((int) $redis->get($keySecond) ?: 0)) ], 'per_hour' => [ 'current' => (int) $redis->get($keyHour) ?: 0, 'limit' => self::RATE_LIMIT_PER_HOUR, 'remaining' => max(0, self::RATE_LIMIT_PER_HOUR - ((int) $redis->get($keyHour) ?: 0)) ], 'per_day' => [ 'current' => (int) $redis->get($keyDay) ?: 0, 'limit' => self::RATE_LIMIT_PER_DAY, 'remaining' => max(0, self::RATE_LIMIT_PER_DAY - ((int) $redis->get($keyDay) ?: 0)) ] ]; } catch (Exception $e) { error_log("Failed to get rate limit stats: " . $e->getMessage()); return [ 'error' => $e->getMessage() ]; } } /** * Resetear límites (solo para testing/debugging) */ public function resetRateLimits(): bool { try { $redis = new Predis\Client([ 'scheme' => getenv('REDIS_SCHEME') ?: 'tcp', 'host' => getenv('REDIS_HOST') ?: '127.0.0.1', 'port' => getenv('REDIS_PORT') ?: 6379, ]); $phoneNumberId = $this->getPhoneNumberId(); $pattern = "whatsapp:ratelimit:*:{$phoneNumberId}:*"; $keys = $redis->keys($pattern); if (!empty($keys)) { $redis->del($keys); } return true; } catch (Exception $e) { error_log("Failed to reset rate limits: " . $e->getMessage()); return false; } } /** * Obtener phone_number_id (acceso protegido) */ private function getPhoneNumberId(): string { // Usar reflection para acceder a propiedad privada del padre $reflection = new ReflectionClass(get_parent_class($this)); $property = $reflection->getProperty('phoneNumberId'); $property->setAccessible(true); return $property->getValue($this) ?: 'default'; } /** * Habilitar/deshabilitar rate limiting */ public function setRateLimitEnabled(bool $enabled): void { $this->rateLimitEnabled = $enabled; } }