<?php
namespace ApplicationBundle\Modules\AiEmployee\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Helper\WhatsAppConfig;
use ApplicationBundle\Interfaces\PublicApiInterface;
use ApplicationBundle\Modules\AiEmployee\Service\CommIngestService;
use ApplicationBundle\Modules\AiEmployee\Service\InboundReplyService;
use ApplicationBundle\Modules\AiEmployee\Service\RelayVaultSink;
use ApplicationBundle\Modules\AiEmployee\Service\WhatsAppInboundService;
use ApplicationBundle\Modules\AiEmployee\Support\WhatsAppPolicyCore;
use CompanyGroupBundle\Entity\BusinessPersona;
use CompanyGroupBundle\Entity\ChannelIdentity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* AIE AE7 — the WhatsApp Cloud API webhook (constitution §5 AE7). Sessionless (PublicApiInterface):
* the signature IS the credential, exactly like the EB ingress. Two jobs on one URL:
* GET — Meta's subscription handshake: echo `hub.challenge` when `hub.verify_token` matches ③.
* POST — inbound messages: verify `X-Hub-Signature-256` (HMAC-SHA256 over the raw body with the
* app secret ④), then hand each message to WhatsAppInboundService, which resolves the
* number → employee → tenant and lands it through AE2 (external posture).
* FAIL-CLOSED: no verify token / app secret configured → the endpoint refuses (nothing runs under
* a guessed identity). Always answers Meta with 200 once verified — the AE2 DLQ (comm_failed_event)
* captures a tenant-DB hiccup for replay, so a message is never lost, and Meta never retry-storms.
*/
class WhatsAppWebhookController extends GenericController implements PublicApiInterface
{
const MAX_BYTES = 3145728; // 3 MB cap (DoS guard)
public function webhookAction(Request $request)
{
if ($request->getMethod() === 'GET') {
return $this->verify($request);
}
return $this->receive($request);
}
/** Meta subscription verification: echo the challenge iff the verify token matches. */
private function verify(Request $request)
{
$verifyToken = WhatsAppConfig::verifyToken();
if ($verifyToken === '') {
return new Response('whatsapp webhook not configured', 503);
}
if ($request->query->get('hub_mode', $request->query->get('hub.mode')) === 'subscribe'
&& hash_equals($verifyToken, (string) $request->query->get('hub_verify_token', $request->query->get('hub.verify_token', '')))) {
$challenge = (string) $request->query->get('hub_challenge', $request->query->get('hub.challenge', ''));
return new Response($challenge, 200, array('Content-Type' => 'text/plain'));
}
return new Response('forbidden', 403);
}
/** Inbound messages: verify signature, then land each message through AE7 → AE2. */
private function receive(Request $request)
{
$raw = (string) $request->getContent();
if (strlen($raw) > self::MAX_BYTES) {
return new JsonResponse(array('error' => 'payload too large'), 413);
}
$secret = WhatsAppConfig::appSecret();
if ($secret === '') {
return new JsonResponse(array('error' => 'whatsapp webhook not configured'), 503);
}
$sig = (string) $request->headers->get('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $sig)) {
return new JsonResponse(array('error' => 'bad signature'), 401);
}
$payload = json_decode($raw, true);
if (!is_array($payload)) {
return new JsonResponse(array('error' => 'malformed'), 400);
}
$cem = $this->getDoctrine()->getManager('company_group');
$ingest = new CommIngestService($cem);
$inbound = new WhatsAppInboundService($cem, $ingest);
$reply = new InboundReplyService($cem, $this->container);
$replyNotes = array();
$attempted = 0;
$landed = 0;
$failed = 0;
$errors = array();
foreach ($this->extractMessages($payload) as $event) {
$attempted++;
$sink = $this->vaultSinkFor($cem, $event['business_number']);
try {
$res = $inbound->ingestWebhook($event, $sink);
// Durably stored, or already stored (dedupe) — both are honest to acknowledge.
if (!empty($res['ok']) || (isset($res['receipt']) && !empty($res['receipt']['deduped']))) {
$landed++;
// THE REPLY TRIGGER — only after the body is DURABLY stored. Drafts only; nothing
// is sent. Its failure is reported, never fatal: the message is already stored, so
// letting a drafting error reach the ack would make Meta retry a message we hold.
// NOT on a deduped receipt: Meta documents that retries cause duplicate deliveries,
// and drafting again for a message we already answered would reply twice.
if (!empty($res['ok']) && empty($res['receipt']['deduped'])) {
$d = $reply->draftReplyTo(array(
'app_id' => isset($res['app_id']) ? $res['app_id'] : null,
'conversation_id' => isset($res['conversation_id']) ? $res['conversation_id'] : (isset($res['receipt']['conversation_id']) ? $res['receipt']['conversation_id'] : null),
'persona_id' => isset($res['persona_id']) ? $res['persona_id'] : null,
'body' => isset($event['body']) ? $event['body'] : '',
'sender_ref' => isset($event['from']) ? $event['from'] : '',
'message_id' => isset($event['message_id']) ? $event['message_id'] : '',
'subject' => 'your WhatsApp message',
));
if (empty($d['drafted'])) { $replyNotes[] = $d['reason']; }
}
} else {
$failed++;
$errors[] = isset($res['reason']) ? (string) $res['reason'] : 'ingest did not land the message';
}
} catch (\Throwable $e) {
// We do NOT swallow this. The old code caught it and still answered 200 — that is how
// the body was destroyed while we told Meta "ok". Count it, and refuse to acknowledge.
$failed++;
$errors[] = $e->getMessage();
}
}
// ★ THE ACK LAW: never acknowledge what we have not durably stored. A 200 makes Meta drop the
// message forever; a non-2xx makes Meta RETAIN it and retry — Meta is the durable buffer, and
// no PII has to rest on central for that (R6 intact). See WhatsAppPolicyCore::ackStatus().
$status = WhatsAppPolicyCore::ackStatus($attempted, $landed, $failed);
if ($status !== 200) {
return new JsonResponse(array(
'status' => 'error',
'error' => 'not stored — retry required',
'attempted' => $attempted,
'landed' => $landed,
'failed' => $failed,
'reasons' => array_slice($errors, 0, 5),
), $status);
}
$ok = array('status' => 'ok', 'attempted' => $attempted, 'landed' => $landed);
if ($replyNotes) { $ok['reply_notes'] = array_slice($replyNotes, 0, 5); } // reported, never fatal
return new JsonResponse($ok, 200);
}
/** Flatten a Cloud API webhook payload into AE7 events (text messages; others recorded as type). */
private function extractMessages(array $payload)
{
$events = array();
foreach (isset($payload['entry']) && is_array($payload['entry']) ? $payload['entry'] : array() as $entry) {
foreach (isset($entry['changes']) && is_array($entry['changes']) ? $entry['changes'] : array() as $change) {
$value = isset($change['value']) && is_array($change['value']) ? $change['value'] : array();
if (empty($value['messages']) || !is_array($value['messages'])) {
continue; // status callbacks etc. — ignore
}
$business = isset($value['metadata']['display_phone_number']) ? (string) $value['metadata']['display_phone_number'] : '';
foreach ($value['messages'] as $m) {
$type = isset($m['type']) ? (string) $m['type'] : 'unknown';
$body = '';
if ($type === 'text' && isset($m['text']['body'])) {
$body = (string) $m['text']['body'];
} elseif (isset($m['button']['text'])) {
$body = (string) $m['button']['text'];
} elseif (isset($m[$type]['caption'])) {
$body = (string) $m[$type]['caption'];
} else {
$body = '[' . $type . ']';
}
$ts = isset($m['timestamp']) && ctype_digit((string) $m['timestamp']) ? (int) $m['timestamp'] : time();
$events[] = array(
'business_number' => $business,
'from' => isset($m['from']) ? (string) $m['from'] : '',
'message_id' => isset($m['id']) ? (string) $m['id'] : '',
'body' => $body,
'group_id' => isset($value['metadata']['group_id']) ? (string) $value['metadata']['group_id'] : (isset($m['group_id']) ? (string) $m['group_id'] : ''),
'occurred_at' => date('Y-m-d H:i:s', $ts),
'sender_display' => isset($value['contacts'][0]['profile']['name']) ? (string) $value['contacts'][0]['profile']['name'] : null,
);
}
}
}
return $events;
}
/**
* CC7 Finding-A FIX: resolve the number → tenant, then return the SIGNED-HTTP RELAY sink for that
* tenant's box. The previous implementation (tenantConnFor) opened a LIVE central→tenant DBAL
* connection off the registry creds — which the CC7 law forbids (localhost-scoped creds +
* credential blast radius), which is impossible when the tenant DB is on another server, and which
* silently destroyed every inbound message on distributed prod. It is deleted, not deprecated.
*
* Returns null when the number is unknown. A null sink makes ingest fail → the ACK LAW returns
* non-2xx → Meta RETAINS the body and retries. We never ack a message we could not place.
*/
private function vaultSinkFor($cem, $businessNumber)
{
try {
$num = WhatsAppPolicyCore::normalizeNumber($businessNumber);
$digits = preg_replace('/\D+/', '', (string) $num);
$identity = null;
foreach (array_unique(array($num, '+' . $digits, $digits)) as $candidate) {
if ($candidate === '') { continue; }
$identity = $cem->getRepository(ChannelIdentity::class)->findOneBy(array('channel' => 'whatsapp', 'address' => $candidate, 'active' => 1));
if ($identity) { break; }
}
if (!$identity) {
return null;
}
$persona = $cem->getRepository(BusinessPersona::class)->find((int) $identity->getPersonaId());
if (!$persona) {
return null;
}
$company = $cem->getRepository('CompanyGroupBundle\Entity\CompanyGroup')->findOneBy(array('appId' => (int) $persona->getAppId()));
if (!$company) {
return null;
}
return RelayVaultSink::fromCompany($company);
} catch (\Throwable $e) {
return null;
}
}
}