Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
WhatsAppService
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 4
420
0.00% covered (danger)
0.00%
0 / 1
 client
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 htmlToText
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 normalizePhone
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 send
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3namespace App\Services;
4
5use Illuminate\Support\Facades\Log;
6use Twilio\Exceptions\TwilioException;
7use Twilio\Rest\Client;
8
9class 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}