src/ApplicationBundle/Modules/AiEmployee/Controller/WhatsAppWebhookController.php line 58

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\AiEmployee\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Helper\WhatsAppConfig;
  5. use ApplicationBundle\Interfaces\PublicApiInterface;
  6. use ApplicationBundle\Modules\AiEmployee\Service\CommIngestService;
  7. use ApplicationBundle\Modules\AiEmployee\Service\InboundReplyService;
  8. use ApplicationBundle\Modules\AiEmployee\Service\RelayVaultSink;
  9. use ApplicationBundle\Modules\AiEmployee\Service\WhatsAppInboundService;
  10. use ApplicationBundle\Modules\AiEmployee\Support\WhatsAppPolicyCore;
  11. use CompanyGroupBundle\Entity\BusinessPersona;
  12. use CompanyGroupBundle\Entity\ChannelIdentity;
  13. use Symfony\Component\HttpFoundation\Request;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use Symfony\Component\HttpFoundation\JsonResponse;
  16. /**
  17.  * AIE AE7 — the WhatsApp Cloud API webhook (constitution §5 AE7). Sessionless (PublicApiInterface):
  18.  * the signature IS the credential, exactly like the EB ingress. Two jobs on one URL:
  19.  *   GET  — Meta's subscription handshake: echo `hub.challenge` when `hub.verify_token` matches ③.
  20.  *   POST — inbound messages: verify `X-Hub-Signature-256` (HMAC-SHA256 over the raw body with the
  21.  *          app secret ④), then hand each message to WhatsAppInboundService, which resolves the
  22.  *          number → employee → tenant and lands it through AE2 (external posture).
  23.  * FAIL-CLOSED: no verify token / app secret configured → the endpoint refuses (nothing runs under
  24.  * a guessed identity). Always answers Meta with 200 once verified — the AE2 DLQ (comm_failed_event)
  25.  * captures a tenant-DB hiccup for replay, so a message is never lost, and Meta never retry-storms.
  26.  */
  27. class WhatsAppWebhookController extends GenericController implements PublicApiInterface
  28. {
  29.     const MAX_BYTES 3145728// 3 MB cap (DoS guard)
  30.     public function webhookAction(Request $request)
  31.     {
  32.         if ($request->getMethod() === 'GET') {
  33.             return $this->verify($request);
  34.         }
  35.         return $this->receive($request);
  36.     }
  37.     /** Meta subscription verification: echo the challenge iff the verify token matches. */
  38.     private function verify(Request $request)
  39.     {
  40.         $verifyToken WhatsAppConfig::verifyToken();
  41.         if ($verifyToken === '') {
  42.             return new Response('whatsapp webhook not configured'503);
  43.         }
  44.         if ($request->query->get('hub_mode'$request->query->get('hub.mode')) === 'subscribe'
  45.             && hash_equals($verifyToken, (string) $request->query->get('hub_verify_token'$request->query->get('hub.verify_token''')))) {
  46.             $challenge = (string) $request->query->get('hub_challenge'$request->query->get('hub.challenge'''));
  47.             return new Response($challenge200, array('Content-Type' => 'text/plain'));
  48.         }
  49.         return new Response('forbidden'403);
  50.     }
  51.     /** Inbound messages: verify signature, then land each message through AE7 → AE2. */
  52.     private function receive(Request $request)
  53.     {
  54.         $raw = (string) $request->getContent();
  55.         if (strlen($raw) > self::MAX_BYTES) {
  56.             return new JsonResponse(array('error' => 'payload too large'), 413);
  57.         }
  58.         $secret WhatsAppConfig::appSecret();
  59.         if ($secret === '') {
  60.             return new JsonResponse(array('error' => 'whatsapp webhook not configured'), 503);
  61.         }
  62.         $sig = (string) $request->headers->get('X-Hub-Signature-256''');
  63.         $expected 'sha256=' hash_hmac('sha256'$raw$secret);
  64.         if (!hash_equals($expected$sig)) {
  65.             return new JsonResponse(array('error' => 'bad signature'), 401);
  66.         }
  67.         $payload json_decode($rawtrue);
  68.         if (!is_array($payload)) {
  69.             return new JsonResponse(array('error' => 'malformed'), 400);
  70.         }
  71.         $cem $this->getDoctrine()->getManager('company_group');
  72.         $ingest = new CommIngestService($cem);
  73.         $inbound = new WhatsAppInboundService($cem$ingest);
  74.         $reply = new InboundReplyService($cem$this->container);
  75.         $replyNotes = array();
  76.         $attempted 0;
  77.         $landed 0;
  78.         $failed 0;
  79.         $errors = array();
  80.         foreach ($this->extractMessages($payload) as $event) {
  81.             $attempted++;
  82.             $sink $this->vaultSinkFor($cem$event['business_number']);
  83.             try {
  84.                 $res $inbound->ingestWebhook($event$sink);
  85.                 // Durably stored, or already stored (dedupe) — both are honest to acknowledge.
  86.                 if (!empty($res['ok']) || (isset($res['receipt']) && !empty($res['receipt']['deduped']))) {
  87.                     $landed++;
  88.                     // THE REPLY TRIGGER — only after the body is DURABLY stored. Drafts only; nothing
  89.                     // is sent. Its failure is reported, never fatal: the message is already stored, so
  90.                     // letting a drafting error reach the ack would make Meta retry a message we hold.
  91.                     // NOT on a deduped receipt: Meta documents that retries cause duplicate deliveries,
  92.                     // and drafting again for a message we already answered would reply twice.
  93.                     if (!empty($res['ok']) && empty($res['receipt']['deduped'])) {
  94.                         $d $reply->draftReplyTo(array(
  95.                             'app_id' => isset($res['app_id']) ? $res['app_id'] : null,
  96.                             'conversation_id' => isset($res['conversation_id']) ? $res['conversation_id'] : (isset($res['receipt']['conversation_id']) ? $res['receipt']['conversation_id'] : null),
  97.                             'persona_id' => isset($res['persona_id']) ? $res['persona_id'] : null,
  98.                             'body' => isset($event['body']) ? $event['body'] : '',
  99.                             'sender_ref' => isset($event['from']) ? $event['from'] : '',
  100.                             'message_id' => isset($event['message_id']) ? $event['message_id'] : '',
  101.                             'subject' => 'your WhatsApp message',
  102.                         ));
  103.                         if (empty($d['drafted'])) { $replyNotes[] = $d['reason']; }
  104.                     }
  105.                 } else {
  106.                     $failed++;
  107.                     $errors[] = isset($res['reason']) ? (string) $res['reason'] : 'ingest did not land the message';
  108.                 }
  109.             } catch (\Throwable $e) {
  110.                 // We do NOT swallow this. The old code caught it and still answered 200 — that is how
  111.                 // the body was destroyed while we told Meta "ok". Count it, and refuse to acknowledge.
  112.                 $failed++;
  113.                 $errors[] = $e->getMessage();
  114.             }
  115.         }
  116.         // ★ THE ACK LAW: never acknowledge what we have not durably stored. A 200 makes Meta drop the
  117.         // message forever; a non-2xx makes Meta RETAIN it and retry — Meta is the durable buffer, and
  118.         // no PII has to rest on central for that (R6 intact). See WhatsAppPolicyCore::ackStatus().
  119.         $status WhatsAppPolicyCore::ackStatus($attempted$landed$failed);
  120.         if ($status !== 200) {
  121.             return new JsonResponse(array(
  122.                 'status' => 'error',
  123.                 'error' => 'not stored — retry required',
  124.                 'attempted' => $attempted,
  125.                 'landed' => $landed,
  126.                 'failed' => $failed,
  127.                 'reasons' => array_slice($errors05),
  128.             ), $status);
  129.         }
  130.         $ok = array('status' => 'ok''attempted' => $attempted'landed' => $landed);
  131.         if ($replyNotes) { $ok['reply_notes'] = array_slice($replyNotes05); } // reported, never fatal
  132.         return new JsonResponse($ok200);
  133.     }
  134.     /** Flatten a Cloud API webhook payload into AE7 events (text messages; others recorded as type). */
  135.     private function extractMessages(array $payload)
  136.     {
  137.         $events = array();
  138.         foreach (isset($payload['entry']) && is_array($payload['entry']) ? $payload['entry'] : array() as $entry) {
  139.             foreach (isset($entry['changes']) && is_array($entry['changes']) ? $entry['changes'] : array() as $change) {
  140.                 $value = isset($change['value']) && is_array($change['value']) ? $change['value'] : array();
  141.                 if (empty($value['messages']) || !is_array($value['messages'])) {
  142.                     continue; // status callbacks etc. — ignore
  143.                 }
  144.                 $business = isset($value['metadata']['display_phone_number']) ? (string) $value['metadata']['display_phone_number'] : '';
  145.                 foreach ($value['messages'] as $m) {
  146.                     $type = isset($m['type']) ? (string) $m['type'] : 'unknown';
  147.                     $body '';
  148.                     if ($type === 'text' && isset($m['text']['body'])) {
  149.                         $body = (string) $m['text']['body'];
  150.                     } elseif (isset($m['button']['text'])) {
  151.                         $body = (string) $m['button']['text'];
  152.                     } elseif (isset($m[$type]['caption'])) {
  153.                         $body = (string) $m[$type]['caption'];
  154.                     } else {
  155.                         $body '[' $type ']';
  156.                     }
  157.                     $ts = isset($m['timestamp']) && ctype_digit((string) $m['timestamp']) ? (int) $m['timestamp'] : time();
  158.                     $events[] = array(
  159.                         'business_number' => $business,
  160.                         'from' => isset($m['from']) ? (string) $m['from'] : '',
  161.                         'message_id' => isset($m['id']) ? (string) $m['id'] : '',
  162.                         'body' => $body,
  163.                         'group_id' => isset($value['metadata']['group_id']) ? (string) $value['metadata']['group_id'] : (isset($m['group_id']) ? (string) $m['group_id'] : ''),
  164.                         'occurred_at' => date('Y-m-d H:i:s'$ts),
  165.                         'sender_display' => isset($value['contacts'][0]['profile']['name']) ? (string) $value['contacts'][0]['profile']['name'] : null,
  166.                     );
  167.                 }
  168.             }
  169.         }
  170.         return $events;
  171.     }
  172.     /**
  173.      * CC7 Finding-A FIX: resolve the number → tenant, then return the SIGNED-HTTP RELAY sink for that
  174.      * tenant's box. The previous implementation (tenantConnFor) opened a LIVE central→tenant DBAL
  175.      * connection off the registry creds — which the CC7 law forbids (localhost-scoped creds +
  176.      * credential blast radius), which is impossible when the tenant DB is on another server, and which
  177.      * silently destroyed every inbound message on distributed prod. It is deleted, not deprecated.
  178.      *
  179.      * Returns null when the number is unknown. A null sink makes ingest fail → the ACK LAW returns
  180.      * non-2xx → Meta RETAINS the body and retries. We never ack a message we could not place.
  181.      */
  182.     private function vaultSinkFor($cem$businessNumber)
  183.     {
  184.         try {
  185.             $num WhatsAppPolicyCore::normalizeNumber($businessNumber);
  186.             $digits preg_replace('/\D+/''', (string) $num);
  187.             $identity null;
  188.             foreach (array_unique(array($num'+' $digits$digits)) as $candidate) {
  189.                 if ($candidate === '') { continue; }
  190.                 $identity $cem->getRepository(ChannelIdentity::class)->findOneBy(array('channel' => 'whatsapp''address' => $candidate'active' => 1));
  191.                 if ($identity) { break; }
  192.             }
  193.             if (!$identity) {
  194.                 return null;
  195.             }
  196.             $persona $cem->getRepository(BusinessPersona::class)->find((int) $identity->getPersonaId());
  197.             if (!$persona) {
  198.                 return null;
  199.             }
  200.             $company $cem->getRepository('CompanyGroupBundle\Entity\CompanyGroup')->findOneBy(array('appId' => (int) $persona->getAppId()));
  201.             if (!$company) {
  202.                 return null;
  203.             }
  204.             return RelayVaultSink::fromCompany($company);
  205.         } catch (\Throwable $e) {
  206.             return null;
  207.         }
  208.     }
  209. }