301 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|