Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MetaWhatsAppProvider
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 11
702
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 send
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sendWithContact
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doSend
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 resolveVariables
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 buildTemplatePayload
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 buildHelloWorldPayload
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 buildTextPayload
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeApiVersion
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 hasOpenConversation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dispatch
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace App\Services\Communication\Providers\WhatsApp;
4
5use App\Services\Communication\Contracts\WhatsAppProviderInterface;
6
7/**
8 * MetaWhatsAppProvider
9 *
10 * Modes d'envoi détectés automatiquement selon les credentials et le message :
11 *
12 *  1. TEMPLATE + VARIABLES  — template_name configuré + message contient {{varName}}
13 *                             Les marqueurs sont résolus depuis $contact et injectés
14 *                             comme paramètres positionnels {{1}}, {{2}}… chez Meta.
15 *
16 *  2. TEMPLATE SEUL         — template_name configuré, aucune variable détectée.
17 *
18 *  3. TEXTE LIBRE           — aucun template_name configuré.
19 *                             Nécessite une fenêtre de conversation < 24 h.
20 *
21 * Credentials attendus :
22 *   access_token      (obligatoire)
23 *   phone_number_id   (obligatoire)
24 *   template_name     (optionnel, défaut : "hello_world" si aucun texte libre)
25 *   template_language (optionnel, défaut : "en_US")
26 *   api_version       (optionnel, défaut : "v20.0")
27 */
28class MetaWhatsAppProvider implements WhatsAppProviderInterface
29{
30    private const FALLBACK_TEMPLATE          = 'hello_world';
31    private const FALLBACK_TEMPLATE_LANGUAGE = 'en_US';
32
33    private string $apiUrl;
34    private string $accessToken;
35    private string $phoneNumberId;
36    private string $templateName;
37    private string $templateLanguage;
38
39    public function __construct(array $credentials)
40    {
41        $this->accessToken      = trim($credentials['access_token']      ?? '');
42        $this->phoneNumberId    = trim($credentials['phone_number_id']   ?? '');
43        $this->templateName     = trim($credentials['template_name']     ?? '');
44        $this->templateLanguage = trim($credentials['template_language'] ?? '');
45        $apiVersion             = $this->normalizeApiVersion($credentials['api_version'] ?? 'v20.0');
46
47        $this->apiUrl = "https://graph.facebook.com/{$apiVersion}/{$this->phoneNumberId}/messages";
48
49        log_message('debug', '[MetaWhatsAppProvider] phone_number_id  = ' . $this->phoneNumberId);
50        log_message('debug', '[MetaWhatsAppProvider] access_token      = ' . substr($this->accessToken, 0, 20) . '...');
51        log_message('debug', '[MetaWhatsAppProvider] api_url           = ' . $this->apiUrl);
52        log_message('debug', '[MetaWhatsAppProvider] template_name     = ' . ($this->templateName ?: '(texte libre → fallback hello_world)'));
53        log_message('debug', '[MetaWhatsAppProvider] template_language = ' . ($this->templateLanguage ?: self::FALLBACK_TEMPLATE_LANGUAGE));
54
55        if (empty($this->accessToken) || empty($this->phoneNumberId)) {
56            throw new \RuntimeException(
57                'MetaWhatsAppProvider : access_token et phone_number_id sont obligatoires'
58            );
59        }
60    }
61
62    // ================================================================
63    // Interface publique
64    // ================================================================
65
66    public function send(string $to, string $message): bool
67    {
68        return $this->doSend($to, $message, []);
69    }
70
71    public function sendWithContact(string $to, string $message, array $contact): bool
72    {
73        return $this->doSend($to, $message, $contact);
74    }
75
76    // ================================================================
77    // Logique centrale
78    // ================================================================
79
80    private function doSend(string $to, string $message, array $contact): bool
81    {
82        $to = ltrim($to, '+'); // Meta attend E.164 sans le +
83
84        if (!empty($this->templateName)) {
85            // Template configuré explicitement dans les credentials
86            $payload = $this->buildTemplatePayload($to, $message, $contact);
87        } elseif ($this->hasOpenConversation()) {
88            // Texte libre — fenêtre < 24h (non détectable côté PHP,
89            // on envoie et Meta rejettera si la fenêtre est fermée)
90            ['text' => $resolved] = $this->resolveVariables($message, $contact);
91            $payload = $this->buildTextPayload($to, $resolved);
92        } else {
93            // Fallback hello_world — aucun template configuré, sécurité maximale
94            $payload = $this->buildHelloWorldPayload($to);
95        }
96
97        log_message('debug', '[MetaWhatsAppProvider] PAYLOAD = ' . json_encode($payload));
98
99        return $this->dispatch($payload);
100    }
101
102    // ================================================================
103    // Résolution des variables  {{name}} → valeur du contact
104    // ================================================================
105
106    /**
107     * @return array{text: string, values: string[]}
108     */
109    private function resolveVariables(string $message, array $contact): array
110    {
111        $values = [];
112
113        $resolved = preg_replace_callback(
114            '/\{\{(\w+)\}\}/',
115            static function (array $m) use ($contact, &$values): string {
116                $key      = $m[1];
117                $value    = isset($contact[$key]) ? (string) $contact[$key] : $m[0];
118                $values[] = $value;
119                return $value;
120            },
121            $message
122        );
123
124        return [
125            'text'   => $resolved,
126            'values' => $values,
127        ];
128    }
129
130    // ================================================================
131    // Construction des payloads
132    // ================================================================
133
134    /**
135     * Template configuré dans les credentials (nom + langue personnalisés).
136     * Injecte les paramètres si des {{variables}} sont présentes.
137     */
138    private function buildTemplatePayload(string $to, string $message, array $contact): array
139    {
140        $template = [
141            'name'     => $this->templateName,
142            'language' => ['code' => $this->templateLanguage ?: self::FALLBACK_TEMPLATE_LANGUAGE],
143        ];
144
145        if (!empty($contact) && preg_match('/\{\{\w+\}\}/', $message)) {
146            ['values' => $values] = $this->resolveVariables($message, $contact);
147
148            $parameters = array_map(
149                static fn(string $v): array => ['type' => 'text', 'text' => $v],
150                $values
151            );
152
153            if (!empty($parameters)) {
154                $template['components'] = [
155                    ['type' => 'body', 'parameters' => $parameters],
156                ];
157            }
158        }
159
160        return [
161            'messaging_product' => 'whatsapp',
162            'to'                => $to,
163            'type'              => 'template',
164            'template'          => $template,
165        ];
166    }
167
168    /**
169     * Fallback hello_world — pré-approuvé sur tous les comptes Meta.
170     * Utilisé quand aucun template_name n'est configuré.
171     * Pas de variables, pas de fenêtre de conversation requise.
172     */
173    private function buildHelloWorldPayload(string $to): array
174    {
175        return [
176            'messaging_product' => 'whatsapp',
177            'to'                => $to,
178            'type'              => 'template',
179            'template'          => [
180                'name'     => self::FALLBACK_TEMPLATE,
181                'language' => ['code' => self::FALLBACK_TEMPLATE_LANGUAGE],
182            ],
183        ];
184    }
185
186    /**
187     * Texte libre — fenêtre de conversation < 24h requise.
188     */
189    private function buildTextPayload(string $to, string $message): array
190    {
191        return [
192            'messaging_product' => 'whatsapp',
193            'to'                => $to,
194            'type'              => 'text',
195            'text'              => ['body' => $message],
196        ];
197    }
198
199    // ================================================================
200    // Helpers
201    // ================================================================
202
203    /**
204     * Normalise api_version :
205     *   "25" | "25.0" | "v25" | "v25.0" → "v25.0"
206     */
207    private function normalizeApiVersion(string $version): string
208    {
209        $numeric = ltrim(trim($version), 'v');
210
211        if (!str_contains($numeric, '.')) {
212            $numeric .= '.0';
213        }
214
215        return 'v' . $numeric;
216    }
217
218    /**
219     * Placeholder — côté PHP on ne peut pas savoir si une fenêtre est ouverte.
220     * On retourne false pour forcer le fallback hello_world par sécurité.
221     * Passez à true uniquement si vous êtes certain que la fenêtre est ouverte.
222     */
223    private function hasOpenConversation(): bool
224    {
225        return false;
226    }
227
228    // ================================================================
229    // Envoi HTTP
230    // ================================================================
231
232    private function dispatch(array $payload): bool
233    {
234        try {
235            $client   = \Config\Services::curlrequest();
236            $response = $client->post($this->apiUrl, [
237                'headers' => [
238                    'Authorization' => 'Bearer ' . $this->accessToken,
239                    'Content-Type'  => 'application/json',
240                ],
241                'json'        => $payload,
242                'http_errors' => false,
243                'timeout'     => 30,
244            ]);
245
246            $status = $response->getStatusCode();
247            $body   = json_decode($response->getBody(), true);
248
249            log_message('debug', '[MetaWhatsAppProvider] HTTP     = ' . $status);
250            log_message('debug', '[MetaWhatsAppProvider] RESPONSE = ' . json_encode($body));
251
252            if ($status !== 200) {
253                log_message('error', '[MetaWhatsAppProvider] FAIL'
254                    . ' | HTTP='    . $status
255                    . ' | code='    . ($body['error']['code']       ?? '?')
256                    . ' | message=' . ($body['error']['message']    ?? '?')
257                    . ' | fbtrace=' . ($body['error']['fbtrace_id'] ?? '?')
258                );
259                return false;
260            }
261
262            if (empty($body['messages'][0]['id'])) {
263                log_message('error', '[MetaWhatsAppProvider] Réponse inattendue : ' . json_encode($body));
264                return false;
265            }
266
267            log_message('info', '[MetaWhatsAppProvider] OK — message_id=' . $body['messages'][0]['id']);
268            return true;
269
270        } catch (\Throwable $e) {
271            log_message('error', '[MetaWhatsAppProvider] Exception : ' . $e->getMessage());
272            return false;
273        }
274    }
275}