Files
whatsapp/services/WhatsAppServiceWithRateLimit.php
T
2026-01-27 23:56:49 -05:00

301 lines
10 KiB
PHP

<?php
/**
* Wrapper mejorado de WhatsAppService con Rate Limiting
*
* Agrega control de límites de envío automático usando Redis
* sin modificar el servicio original
*/
require_once __DIR__ . '/../services/WhatsAppService.php';
use WhatsApp\Queue\RedisQueue;
class WhatsAppServiceWithRateLimit extends WhatsAppService {
private $queue;
private $rateLimitEnabled = true;
// Límites de WhatsApp Business API
const RATE_LIMIT_PER_SECOND = 80;
const RATE_LIMIT_PER_HOUR = 1000;
const RATE_LIMIT_PER_DAY = 10000;
public function __construct() {
parent::__construct();
// Inicializar cola para rate limiting
$this->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;
}
}