Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SmsWorker
0.00% covered (danger)
0.00%
0 / 186
0.00% covered (danger)
0.00%
0 / 13
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 initializeTwilioClient
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 processQueue
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 sendMessage
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
56
 handleSuccess
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 handleFailure
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 retryFailed
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
110
 getProcessingStats
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 validatePhoneNumber
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 formatPhoneNumber
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 rateLimit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 calculateBackoff
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 log
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace BuyerKiosk\SellerMarketing;
4
5// Include the modern Twilio autoloader
6require_once($_ENV['HOME_DIR'] . '/public_html/api/model/Twilio/autoload.php');
7
8use Twilio\Rest\Client;
9use Twilio\Exceptions\TwilioException;
10use Twilio\Exceptions\RestException;
11
12/**
13 * SmsWorker Class
14 *
15 * Processes SMS messages from queue and sends them via Twilio.
16 * Handles rate limiting, retries, and error tracking.
17 *
18 * @package BuyerKiosk\SellerMarketing
19 */
20class SmsWorker
21{
22    /**
23     * @var SmsQueue Queue manager instance
24     */
25    private $smsQueue;
26
27    /**
28     * @var \Store Store instance for Twilio configuration
29     */
30    private $store;
31
32    /**
33     * @var mixed Optional logger instance
34     */
35    private $logger;
36
37    /**
38     * @var Client Twilio client instance
39     */
40    private $twilioClient;
41
42    /**
43     * Rate limiting configuration
44     */
45    const DEFAULT_RATE_LIMIT = 10; // messages per second
46    const DEFAULT_BATCH_SIZE = 10;
47    const DEFAULT_MAX_RETRIES = 3;
48    const RETRY_BACKOFF_SECONDS = 60; // Wait before retrying failed messages
49
50    /**
51     * Twilio error codes that should not be retried
52     */
53    const NON_RETRYABLE_ERRORS = [
54        21211, // Invalid 'To' phone number
55        21612, // 'To' phone number cannot receive SMS
56        21614, // 'To' number is not a valid mobile number
57        21408, // Permission to send SMS has not been enabled
58        21610, // Message cannot be sent to the 'To' number (landline or unreachable carrier)
59    ];
60
61    /**
62     * Constructor
63     *
64     * @param SmsQueue $smsQueue Queue manager instance
65     * @param \Store $store Store instance for Twilio configuration
66     * @param mixed $logger Optional logger instance (KLogger or compatible)
67     */
68    public function __construct($smsQueue, $store, $logger = null)
69    {
70        $this->smsQueue = $smsQueue;
71        $this->store = $store;
72        $this->logger = $logger;
73
74        // Initialize Twilio client
75        $this->initializeTwilioClient();
76    }
77
78    /**
79     * Initialize Twilio client
80     *
81     * @return void
82     * @throws \Exception If Twilio credentials are not configured
83     */
84    private function initializeTwilioClient()
85    {
86        if (empty($_ENV['TWILIO_SID']) || empty($_ENV['TWILIO_TOKEN'])) {
87            throw new \Exception("Twilio credentials not configured in environment");
88        }
89
90        $this->twilioClient = new Client($_ENV['TWILIO_SID'], $_ENV['TWILIO_TOKEN']);
91        $this->log("Twilio client initialized");
92    }
93
94    /**
95     * Process a batch of messages from the queue
96     *
97     * Retrieves pending messages, attempts to send them via Twilio,
98     * and tracks success/failure status.
99     *
100     * @param string $typeNum Store identifier (e.g., 'ou00', 'pa00')
101     * @param int $batchSize Number of messages to process (default: 10)
102     * @return array Statistics: ['processed' => int, 'sent' => int, 'failed' => int]
103     */
104    public function processQueue($typeNum, $batchSize = self::DEFAULT_BATCH_SIZE)
105    {
106        $stats = [
107            'processed' => 0,
108            'sent' => 0,
109            'failed' => 0,
110            'skipped' => 0
111        ];
112
113        try {
114            $this->log("Starting queue processing for {$typeNum}, batch size: {$batchSize}");
115
116            // Get pending messages from queue
117            $pendingMessages = $this->smsQueue->getPending($typeNum, $batchSize);
118
119            if (empty($pendingMessages)) {
120                $this->log("No pending messages found for {$typeNum}");
121                return $stats;
122            }
123
124            $this->log("Found " . count($pendingMessages) . " pending messages for {$typeNum}");
125
126            // Process each message
127            foreach ($pendingMessages as $queueItem) {
128                $stats['processed']++;
129
130                // Attempt to atomically claim the message
131                if (!$this->smsQueue->markSending($queueItem['id'])) {
132                    $this->log("Failed to claim message {$queueItem['id']}, may have been claimed by another process");
133                    $stats['skipped']++;
134                    continue;
135                }
136
137                // Send the message
138                if ($this->sendMessage($queueItem)) {
139                    $stats['sent']++;
140                } else {
141                    $stats['failed']++;
142                }
143
144                // Implement rate limiting to respect Twilio limits
145                $this->rateLimit();
146            }
147
148            $this->log("Queue processing completed for {$typeNum}" . json_encode($stats));
149
150        } catch (\Exception $e) {
151            $this->log("Error processing queue for {$typeNum}" . $e->getMessage());
152        }
153
154        return $stats;
155    }
156
157    /**
158     * Send a single message via Twilio
159     *
160     * Attempts to send the message and updates queue status accordingly.
161     *
162     * @param array $queueItem Queue item with message details
163     * @return bool True if sent successfully, false otherwise
164     */
165    public function sendMessage($queueItem)
166    {
167        $queueId = $queueItem['id'];
168        $phone = $queueItem['customer_phone'];
169        $messageText = $queueItem['message_text'];
170
171        try {
172            $this->log("Attempting to send message {$queueId} to {$phone}");
173
174            // Validate phone number format
175            if (!$this->validatePhoneNumber($phone)) {
176                $error = "Invalid phone number format: {$phone}";
177                $this->log($error);
178                $this->handleFailure($queueId, $error, false); // Non-retryable
179                return false;
180            }
181
182            // Get Twilio phone number from store
183            $fromPhone = $this->store->getTwilioPhone();
184            if (empty($fromPhone) || strlen($fromPhone) < 10) {
185                $error = "Store Twilio phone not configured";
186                $this->log($error);
187                $this->handleFailure($queueId, $error, true); // Retryable
188                return false;
189            }
190
191            // Ensure phone numbers are in E.164 format
192            $toPhone = $this->formatPhoneNumber($phone);
193            $fromPhone = $this->formatPhoneNumber($fromPhone);
194
195            // Send via Twilio
196            $message = $this->twilioClient->messages->create(
197                $toPhone,
198                [
199                    'from' => $fromPhone,
200                    'body' => $messageText
201                ]
202            );
203
204            // Message sent successfully
205            $this->handleSuccess($queueId, $message->sid);
206            $this->log("Message {$queueId} sent successfully: SID={$message->sid}");
207
208            return true;
209
210        } catch (RestException $e) {
211            // Handle Twilio-specific errors
212            $errorCode = $e->getCode();
213            $errorMessage = $e->getMessage();
214            $isRetryable = !in_array($errorCode, self::NON_RETRYABLE_ERRORS);
215
216            $this->log("Twilio error sending message {$queueId}: Code={$errorCode}, Message={$errorMessage}");
217            $this->handleFailure($queueId, "Twilio error {$errorCode}{$errorMessage}", $isRetryable);
218
219            return false;
220
221        } catch (TwilioException $e) {
222            // Handle general Twilio exceptions
223            $errorMessage = $e->getMessage();
224            $this->log("Twilio exception sending message {$queueId}{$errorMessage}");
225            $this->handleFailure($queueId, "Twilio exception: {$errorMessage}", true);
226
227            return false;
228
229        } catch (\Exception $e) {
230            // Handle unexpected errors
231            $errorMessage = $e->getMessage();
232            $this->log("Unexpected error sending message {$queueId}{$errorMessage}");
233            $this->handleFailure($queueId, "Unexpected error: {$errorMessage}", true);
234
235            return false;
236        }
237    }
238
239    /**
240     * Handle successful message send
241     *
242     * Updates queue status to 'sent' and stores Twilio SID.
243     *
244     * @param int $queueId Queue message ID
245     * @param string $twilioSid Twilio message SID
246     * @return bool True on success, false on failure
247     */
248    public function handleSuccess($queueId, $twilioSid)
249    {
250        try {
251            return $this->smsQueue->markSent($queueId, $twilioSid);
252        } catch (\Exception $e) {
253            $this->log("Error marking message {$queueId} as sent: " . $e->getMessage());
254            return false;
255        }
256    }
257
258    /**
259     * Handle failed message send
260     *
261     * Updates queue status to 'failed' and stores error message.
262     * For retryable errors, message remains eligible for retry.
263     *
264     * @param int $queueId Queue message ID
265     * @param string $error Error description
266     * @param bool $isRetryable Whether this error should allow retry (default: true)
267     * @return bool True on success, false on failure
268     */
269    public function handleFailure($queueId, $error, $isRetryable = true)
270    {
271        try {
272            // Prefix error with retry status for clarity
273            $errorPrefix = $isRetryable ? '[RETRYABLE]' : '[PERMANENT]';
274            $fullError = "{$errorPrefix} {$error}";
275
276            return $this->smsQueue->markFailed($queueId, $fullError);
277        } catch (\Exception $e) {
278            $this->log("Error marking message {$queueId} as failed: " . $e->getMessage());
279            return false;
280        }
281    }
282
283    /**
284     * Retry previously failed messages
285     *
286     * Retrieves failed messages eligible for retry, increments retry counter,
287     * and attempts to send them again.
288     *
289     * @param string $typeNum Store identifier
290     * @param int $maxRetries Maximum retry attempts (default: 3)
291     * @return array Statistics: ['processed' => int, 'sent' => int, 'failed' => int]
292     */
293    public function retryFailed($typeNum, $maxRetries = self::DEFAULT_MAX_RETRIES)
294    {
295        $stats = [
296            'processed' => 0,
297            'sent' => 0,
298            'failed' => 0,
299            'skipped' => 0
300        ];
301
302        try {
303            $this->log("Starting retry processing for {$typeNum}, max retries: {$maxRetries}");
304
305            // Get retryable messages
306            $retryableMessages = $this->smsQueue->getRetryable($typeNum, $maxRetries);
307
308            if (empty($retryableMessages)) {
309                $this->log("No retryable messages found for {$typeNum}");
310                return $stats;
311            }
312
313            $this->log("Found " . count($retryableMessages) . " retryable messages for {$typeNum}");
314
315            // Process each retryable message
316            foreach ($retryableMessages as $queueItem) {
317                $stats['processed']++;
318                $queueId = $queueItem['id'];
319
320                // Check if error is non-retryable (based on error message)
321                if (isset($queueItem['error_message']) &&
322                    strpos($queueItem['error_message'], '[PERMANENT]') !== false) {
323                    $this->log("Skipping message {$queueId}: Permanent error");
324                    $stats['skipped']++;
325                    continue;
326                }
327
328                // Increment retry counter
329                if (!$this->smsQueue->incrementRetry($queueId)) {
330                    $this->log("Failed to increment retry for message {$queueId}");
331                    $stats['skipped']++;
332                    continue;
333                }
334
335                // Message is now back in 'queued' status, try to claim and send
336                if (!$this->smsQueue->markSending($queueId)) {
337                    $this->log("Failed to claim message {$queueId} for retry");
338                    $stats['skipped']++;
339                    continue;
340                }
341
342                // Calculate backoff delay based on retry count
343                $retryCount = (int)$queueItem['retry_count'] + 1; // +1 because we just incremented
344                $backoffSeconds = $this->calculateBackoff($retryCount);
345                if ($backoffSeconds > 0) {
346                    $this->log("Applying backoff of {$backoffSeconds}s for retry {$retryCount} of message {$queueId}");
347                    sleep($backoffSeconds);
348                }
349
350                // Attempt to send
351                if ($this->sendMessage($queueItem)) {
352                    $stats['sent']++;
353                    $this->log("Retry successful for message {$queueId}");
354                } else {
355                    $stats['failed']++;
356                    $this->log("Retry failed for message {$queueId}");
357                }
358
359                // Rate limit between retries
360                $this->rateLimit();
361            }
362
363            $this->log("Retry processing completed for {$typeNum}" . json_encode($stats));
364
365        } catch (\Exception $e) {
366            $this->log("Error retrying failed messages for {$typeNum}" . $e->getMessage());
367        }
368
369        return $stats;
370    }
371
372    /**
373     * Get processing statistics
374     *
375     * Returns current queue statistics and processing metrics.
376     *
377     * @param string $typeNum Store identifier
378     * @return array|false Statistics array or false on failure
379     */
380    public function getProcessingStats($typeNum)
381    {
382        try {
383            $this->log("Retrieving processing stats for {$typeNum}");
384
385            // Get queue stats from SmsQueue
386            $queueStats = $this->smsQueue->getQueueStats($typeNum);
387
388            if ($queueStats === false) {
389                $this->log("Failed to retrieve queue stats for {$typeNum}");
390                return false;
391            }
392
393            // Add additional processing metrics
394            $stats = [
395                'queue_stats' => $queueStats,
396                'total_messages' => array_sum($queueStats),
397                'pending_count' => $queueStats[SmsQueue::STATUS_QUEUED] ?? 0,
398                'processing_count' => $queueStats[SmsQueue::STATUS_SENDING] ?? 0,
399                'sent_count' => $queueStats[SmsQueue::STATUS_SENT] ?? 0,
400                'delivered_count' => $queueStats[SmsQueue::STATUS_DELIVERED] ?? 0,
401                'failed_count' => $queueStats[SmsQueue::STATUS_FAILED] ?? 0,
402                'success_rate' => 0,
403                'timestamp' => date('Y-m-d H:i:s')
404            ];
405
406            // Calculate success rate
407            $totalProcessed = $stats['sent_count'] + $stats['failed_count'];
408            if ($totalProcessed > 0) {
409                $stats['success_rate'] = round(($stats['sent_count'] / $totalProcessed) * 100, 2);
410            }
411
412            $this->log("Processing stats for {$typeNum}" . json_encode($stats));
413
414            return $stats;
415
416        } catch (\Exception $e) {
417            $this->log("Error getting processing stats for {$typeNum}" . $e->getMessage());
418            return false;
419        }
420    }
421
422    /**
423     * Validate phone number format
424     *
425     * Ensures phone number is in a valid format for Twilio.
426     *
427     * @param string $phone Phone number to validate
428     * @return bool True if valid format
429     */
430    private function validatePhoneNumber($phone)
431    {
432        // Remove whitespace
433        $phone = trim($phone);
434
435        // Check for empty
436        if (empty($phone)) {
437            return false;
438        }
439
440        // Must be 10-16 characters (with or without +)
441        $length = strlen($phone);
442        if ($length < 10 || $length > 16) {
443            return false;
444        }
445
446        // Must contain only digits and optionally start with +
447        if (!preg_match('/^\+?\d{10,15}$/', $phone)) {
448            return false;
449        }
450
451        return true;
452    }
453
454    /**
455     * Format phone number to E.164 format
456     *
457     * Ensures phone number starts with + and country code.
458     *
459     * @param string $phone Phone number to format
460     * @return string Formatted phone number
461     */
462    private function formatPhoneNumber($phone)
463    {
464        // Remove whitespace and common separators
465        $phone = preg_replace('/[\s\-\(\)\.]+/', '', $phone);
466
467        // If already starts with +, assume it's correct
468        if (substr($phone, 0, 1) === '+') {
469            return $phone;
470        }
471
472        // If starts with 1 (US/Canada country code), add +
473        if (substr($phone, 0, 1) === '1' && strlen($phone) === 11) {
474            return '+' . $phone;
475        }
476
477        // Otherwise assume US/Canada and prepend +1
478        return '+1' . $phone;
479    }
480
481    /**
482     * Implement rate limiting
483     *
484     * Adds delay between message sends to respect Twilio rate limits.
485     * Default is 10 messages per second (100ms delay).
486     *
487     * @return void
488     */
489    private function rateLimit()
490    {
491        // Calculate delay in microseconds to achieve target rate
492        $delayMicroseconds = (int)((1 / self::DEFAULT_RATE_LIMIT) * 1000000);
493
494        // Apply delay
495        usleep($delayMicroseconds);
496    }
497
498    /**
499     * Calculate exponential backoff delay for retries
500     *
501     * Implements exponential backoff: 60s, 120s, 240s, etc.
502     *
503     * @param int $retryCount Current retry attempt number (1-based)
504     * @return int Seconds to wait before retry
505     */
506    private function calculateBackoff($retryCount)
507    {
508        if ($retryCount <= 1) {
509            return 0; // No delay for first attempt
510        }
511
512        // Exponential backoff: base_delay * (2 ^ (retry - 1))
513        // Capped at 1 hour (3600 seconds)
514        $delay = self::RETRY_BACKOFF_SECONDS * pow(2, $retryCount - 2);
515        return min($delay, 3600);
516    }
517
518    /**
519     * Log a message
520     *
521     * Uses logger if provided, otherwise falls back to error_log.
522     *
523     * @param string $message Log message
524     * @return void
525     */
526    private function log($message)
527    {
528        $logMessage = "[SmsWorker] " . $message;
529
530        if ($this->logger !== null) {
531            // Try different logging methods depending on logger type
532            if (method_exists($this->logger, 'info')) {
533                $this->logger->info($logMessage);
534            } elseif (method_exists($this->logger, 'LogInfo')) {
535                $this->logger->LogInfo($logMessage);
536            } elseif (is_callable($this->logger)) {
537                call_user_func($this->logger, $logMessage);
538            } else {
539                error_log($logMessage);
540            }
541        } else {
542            error_log($logMessage);
543        }
544    }
545}