Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 111 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
| MetaWhatsAppProvider | |
0.00% |
0 / 111 |
|
0.00% |
0 / 11 |
702 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 | |||
| send | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| sendWithContact | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| doSend | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| resolveVariables | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
| buildTemplatePayload | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
30 | |||
| buildHelloWorldPayload | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| buildTextPayload | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| normalizeApiVersion | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| hasOpenConversation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| dispatch | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services\Communication\Providers\WhatsApp; |
| 4 | |
| 5 | use 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 | */ |
| 28 | class 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 | } |