Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 72 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
| WhatsAppService | |
0.00% |
0 / 72 |
|
0.00% |
0 / 4 |
420 | |
0.00% |
0 / 1 |
| client | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
| htmlToText | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
| normalizePhone | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
| send | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
90 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services; |
| 4 | |
| 5 | use Illuminate\Support\Facades\Log; |
| 6 | use Twilio\Exceptions\TwilioException; |
| 7 | use Twilio\Rest\Client; |
| 8 | |
| 9 | class WhatsAppService |
| 10 | { |
| 11 | private ?Client $client = null; |
| 12 | |
| 13 | private function client(): ?Client |
| 14 | { |
| 15 | if ($this->client) { |
| 16 | return $this->client; |
| 17 | } |
| 18 | |
| 19 | $sid = config('services.twilio.sid'); |
| 20 | $token = config('services.twilio.token'); |
| 21 | |
| 22 | if (empty($sid) || empty($token)) { |
| 23 | return null; |
| 24 | } |
| 25 | |
| 26 | $this->client = new Client($sid, $token); |
| 27 | |
| 28 | return $this->client; |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * Strip HTML and decode entities for plain-text WhatsApp delivery. |
| 33 | * Preserves line breaks: <br>, <p>, <div>, <li> become newlines. |
| 34 | */ |
| 35 | public function htmlToText(?string $html): string |
| 36 | { |
| 37 | if (empty($html)) { |
| 38 | return ''; |
| 39 | } |
| 40 | |
| 41 | $text = preg_replace('/<br\s*\/?>/i', "\n", $html); |
| 42 | $text = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', "\n", $text); |
| 43 | $text = preg_replace('/<li[^>]*>/i', '• ', $text); |
| 44 | $text = strip_tags($text); |
| 45 | $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 46 | $text = preg_replace("/[ \t]+/", ' ', $text); |
| 47 | $text = preg_replace("/\n{3,}/", "\n\n", $text); |
| 48 | |
| 49 | return trim($text); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Normalize a phone number into E.164 (e.g. +34612345678). |
| 54 | * Returns null if the number cannot be normalized. |
| 55 | */ |
| 56 | public function normalizePhone(?string $phone): ?string |
| 57 | { |
| 58 | if (empty($phone)) { |
| 59 | return null; |
| 60 | } |
| 61 | |
| 62 | $digits = preg_replace('/\D+/', '', $phone); |
| 63 | if (empty($digits)) { |
| 64 | return null; |
| 65 | } |
| 66 | |
| 67 | // Already has country code if starts with 00 or matches expected length. |
| 68 | if (str_starts_with($phone, '+')) { |
| 69 | return '+'.$digits; |
| 70 | } |
| 71 | if (str_starts_with($digits, '00')) { |
| 72 | return '+'.substr($digits, 2); |
| 73 | } |
| 74 | |
| 75 | $cc = (string) config('services.twilio.default_country_code', '34'); |
| 76 | |
| 77 | return '+'.$cc.$digits; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Send a WhatsApp message via Twilio. |
| 82 | * |
| 83 | * @return array{ok: bool, sid: ?string, status: ?string, error: ?string} |
| 84 | */ |
| 85 | public function send(?string $toPhone, ?string $htmlBody, ?array $mediaUrls = null): array |
| 86 | { |
| 87 | $client = $this->client(); |
| 88 | if (! $client) { |
| 89 | return ['ok' => false, 'sid' => null, 'status' => null, 'error' => 'Twilio not configured']; |
| 90 | } |
| 91 | |
| 92 | $to = $this->normalizePhone($toPhone); |
| 93 | if (! $to) { |
| 94 | return ['ok' => false, 'sid' => null, 'status' => null, 'error' => 'Invalid phone']; |
| 95 | } |
| 96 | |
| 97 | $body = $this->htmlToText($htmlBody); |
| 98 | if ($body === '') { |
| 99 | return ['ok' => false, 'sid' => null, 'status' => null, 'error' => 'Empty message body']; |
| 100 | } |
| 101 | |
| 102 | $from = (string) config('services.twilio.whatsapp_from'); |
| 103 | if ($from === '') { |
| 104 | return ['ok' => false, 'sid' => null, 'status' => null, 'error' => 'whatsapp_from not configured']; |
| 105 | } |
| 106 | |
| 107 | // Staging diversion: route every outbound to a single test number. |
| 108 | if (config('services.twilio.staging')) { |
| 109 | $stagingTo = config('services.twilio.staging_to'); |
| 110 | if (! empty($stagingTo)) { |
| 111 | $to = $stagingTo; |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | $payload = ['from' => 'whatsapp:'.$from, 'body' => $body]; |
| 116 | if (! empty($mediaUrls)) { |
| 117 | $payload['mediaUrl'] = array_values($mediaUrls); |
| 118 | } |
| 119 | |
| 120 | try { |
| 121 | $message = $client->messages->create('whatsapp:'.$to, $payload); |
| 122 | |
| 123 | Log::info('WhatsApp sent', [ |
| 124 | 'to' => $to, |
| 125 | 'sid' => $message->sid, |
| 126 | 'status' => $message->status, |
| 127 | 'staging' => (bool) config('services.twilio.staging'), |
| 128 | ]); |
| 129 | |
| 130 | return [ |
| 131 | 'ok' => true, |
| 132 | 'sid' => $message->sid, |
| 133 | 'status' => $message->status, |
| 134 | 'error' => null, |
| 135 | ]; |
| 136 | } catch (TwilioException $e) { |
| 137 | Log::warning('WhatsApp send failed', [ |
| 138 | 'to' => $to, |
| 139 | 'error' => $e->getMessage(), |
| 140 | ]); |
| 141 | |
| 142 | return [ |
| 143 | 'ok' => false, |
| 144 | 'sid' => null, |
| 145 | 'status' => 'Error', |
| 146 | 'error' => $e->getMessage(), |
| 147 | ]; |
| 148 | } |
| 149 | } |
| 150 | } |