'Verificación fallida'], JSON_UNESCAPED_UNICODE); exit; } // ─── POST: Recepción de eventos ─────────────────────────────────────────── public static function receive(): void { $rawBody = file_get_contents('php://input'); $headers = getallheaders(); self::debugLog('POST', json_encode(['headers' => $headers, 'body' => $rawBody], JSON_UNESCAPED_UNICODE)); // Guardar raw body para los handlers self::$currentRaw = $rawBody ?: ''; // Guardar SIEMPRE una entrada raw en webhook_logs antes de cualquier validación self::saveRawIncoming($rawBody ?: '', $headers); if ($rawBody === false || $rawBody === '') { self::respond(400, ['error' => 'Body vacío']); } self::verifySignature($rawBody); $payload = json_decode($rawBody, true); if (!is_array($payload)) { self::respond(400, ['error' => 'JSON inválido']); } self::processEvent($payload, $rawBody); self::respond(200, ['status' => 'received']); } private static function saveRawIncoming(string $rawBody, array $headers): void { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $preview = mb_substr($rawBody, 0, 300); $sigOk = 'pendiente'; $appSecret = env('WHATSAPP_APP_SECRET', ''); $hmacEnabled = env('verify_hmac_signature', '1'); if ($hmacEnabled !== '0' && $appSecret !== '') { $sigHeader = $headers['X-Hub-Signature-256'] ?? $headers['x-hub-signature-256'] ?? ''; if (strpos($sigHeader, 'sha256=') === 0) { $received = substr($sigHeader, 7); $expected = hash_hmac('sha256', $rawBody, $appSecret); $sigOk = hash_equals($expected, $received) ? 'ok' : 'invalida'; } else { $sigOk = 'sin-firma'; } } else { $sigOk = 'desactivada'; } try { $stmt = db()->prepare(" INSERT INTO webhook_logs (company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload) VALUES (NULL, 'raw', ?, ?, 'raw', ?, ?) "); $stmt->execute([$ip, "HMAC:{$sigOk}", mb_substr($preview, 0, 500), $rawBody]); } catch (\PDOException $e) { self::log('ERROR', 'saveRawIncoming DB: ' . $e->getMessage()); } } // ─── Procesamiento de eventos ───────────────────────────────────────────── private static function processEvent(array $payload, string $raw): void { $object = $payload['object'] ?? ''; if ($object !== 'whatsapp_business_account') { self::log('WARN', "Objeto desconocido: $object"); return; } self::resolveCompany($payload); $entries = $payload['entry'] ?? []; foreach ($entries as $entry) { $changes = $entry['changes'] ?? []; foreach ($changes as $change) { $field = $change['field'] ?? ''; $value = $change['value'] ?? []; if ($field === 'messages' || $field === 'conversations') { self::handleMessages($value); } elseif ($field === 'statuses') { self::handleStatuses($value); } else { self::log('INFO', "Campo no manejado: $field"); } } } // Persistir evento crudo para auditoría self::saveRawEvent($raw); // Procesar cola outbound inmediatamente para respuestas automáticas try { OutboundWorker::processQueue(); } catch (\Throwable $e) { self::log('WARN', 'OutboundWorker falló: ' . $e->getMessage()); } } private static function resolveCompany(array $payload): void { // Fallback: resolver por phone_number_id (usado para statuses y auditoría) $phoneNumberId = ''; foreach ($payload['entry'] ?? [] as $entry) { foreach ($entry['changes'] ?? [] as $change) { $metadata = $change['value']['metadata'] ?? []; if (!empty($metadata['phone_number_id'])) { $phoneNumberId = $metadata['phone_number_id']; break 2; } } } if ($phoneNumberId !== '') { self::$currentCompany = CompanyRepository::findByPhoneNumberId($phoneNumberId); } } // Resolución principal: busca el número remitente en company_phones private static function resolveCompanyByNumber(string $from): bool { if ($from === '') { self::$currentCompany = null; return false; } try { $stmt = db()->prepare(" SELECT cp.company_id, cp.permission_type FROM company_phones cp WHERE cp.wa_number = ? AND cp.is_active = 1 LIMIT 1 "); $stmt->execute([$from]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); } catch (\PDOException $e) { self::log('ERROR', 'resolveCompanyByNumber DB: ' . $e->getMessage()); self::$currentCompany = null; return false; } if (!$row) { self::$currentCompany = null; self::log('WARN', "Número {$from} no registrado en ninguna empresa"); return false; } $company = CompanyRepository::findById((int)$row['company_id']); if ($company === null) { self::$currentCompany = null; return false; } // Adjuntar tipo de permiso al contexto de empresa $company['_permission_type'] = (int)$row['permission_type']; self::$currentCompany = $company; self::log('INFO', "Número {$from} → empresa: {$company['name']} (permiso tipo {$row['permission_type']})"); return true; } private static function sendRejectionMessage(string $to, string $phoneNumberId): void { $token = env('WHATSAPP_ACCESS_TOKEN', ''); if ($token === '' || $to === '' || $phoneNumberId === '') return; $body = json_encode([ 'messaging_product' => 'whatsapp', 'to' => $to, 'type' => 'text', 'text' => ['body' => 'Comuníquese con el administrador, su número no se encuentra habilitado.'], ]); $ch = curl_init("https://graph.facebook.com/v17.0/{$phoneNumberId}/messages"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); self::log('INFO', "Rechazo enviado a {$to} HTTP {$code}"); } // ─── Reenvío a empresa ──────────────────────────────────────────────────── private static function forwardToCompany(array $context, string $content, ?string $mediaId = null): void { if (self::$currentCompany === null) { return; } $messageData = array_merge($context, [ 'content' => $content, 'media_id' => $mediaId, 'raw_payload' => self::$currentRaw, ]); $result = CompanyApiClient::forwardMessage(self::$currentCompany, $messageData); if ($result['success']) { self::log('INFO', "Mensaje reenviado a empresa: " . (self::$currentCompany['name'] ?? '?')); } else { self::log('ERROR', "Fallo reenvío a empresa: " . ($result['error'] ?? json_encode($result))); } } // ─── Mensajes entrantes ─────────────────────────────────────────────────── private static function handleMessages(array $value): void { $messages = $value['messages'] ?? []; $contacts = $value['contacts'] ?? []; $metadata = $value['metadata'] ?? []; $phoneNumberId = $metadata['phone_number_id'] ?? ''; $displayPhone = $metadata['display_phone_number'] ?? ''; foreach ($messages as $msg) { $from = $msg['from'] ?? ''; $msgId = $msg['id'] ?? ''; $type = $msg['type'] ?? 'unknown'; $ts = $msg['timestamp'] ?? time(); $name = ''; foreach ($contacts as $c) { if (($c['wa_id'] ?? '') === $from) { $name = $c['profile']['name'] ?? ''; break; } } // Resolver empresa por número remitente $allowed = self::resolveCompanyByNumber($from); if (!$allowed) { self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]'); self::sendRejectionMessage($from, $phoneNumberId); continue; } $context = [ 'from' => $from, 'name' => $name, 'message_id' => $msgId, 'type' => $type, 'timestamp' => $ts, 'phone_number_id' => $phoneNumberId, 'display_phone' => $displayPhone, 'permission_type' => self::$currentCompany['_permission_type'] ?? 3, ]; switch ($type) { case 'text': self::handleText($msg, $context); break; case 'image': self::handleMedia($msg, $context, 'image'); break; case 'audio': self::handleMedia($msg, $context, 'audio'); break; case 'video': self::handleMedia($msg, $context, 'video'); break; case 'document': self::handleMedia($msg, $context, 'document'); break; case 'sticker': self::handleMedia($msg, $context, 'sticker'); break; case 'location': self::handleLocation($msg, $context); break; case 'interactive': self::handleInteractive($msg, $context); break; case 'button': self::handleButton($msg, $context); break; case 'reaction': self::handleReaction($msg, $context); break; default: self::log('INFO', "Tipo no manejado: $type | from=$from"); break; } } if (!empty($value['statuses'])) { self::handleStatuses($value); } } private static function handleText(array $msg, array $ctx): void { $body = $msg['text']['body'] ?? ''; self::log('MSG', "[TEXT] {$ctx['from']} ({$ctx['name']}): $body"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'text', $body); self::saveConversation($ctx, $body); self::forwardToCompany($ctx, $body); if (self::$currentCompany !== null) { BotRouter::route(self::$currentCompany, $ctx, $body, 'text'); } } private static function handleMedia(array $msg, array $ctx, string $type): void { $data = $msg[$type] ?? []; $mediaId = $data['id'] ?? ''; $mime = $data['mime_type'] ?? ''; $caption = $data['caption'] ?? ''; $preview = $caption ?: "[$type id:$mediaId]"; self::log('MSG', "[" . strtoupper($type) . "] {$ctx['from']} | id=$mediaId mime=$mime caption=$caption"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview); self::saveConversation($ctx, $preview, $mediaId); self::forwardToCompany($ctx, $preview, $mediaId); if (self::$currentCompany !== null) { BotRouter::route(self::$currentCompany, $ctx, $caption, $type); } } private static function handleLocation(array $msg, array $ctx): void { $lat = $msg['location']['latitude'] ?? ''; $lng = $msg['location']['longitude'] ?? ''; $name = $msg['location']['name'] ?? ''; $preview = "lat:$lat lng:$lng" . ($name ? " ($name)" : ''); self::log('MSG', "[LOCATION] {$ctx['from']} | lat=$lat lng=$lng name=$name"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'location', $preview); self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); } private static function handleInteractive(array $msg, array $ctx): void { $iType = $msg['interactive']['type'] ?? ''; if ($iType === 'button_reply') { $reply = $msg['interactive']['button_reply'] ?? []; } elseif ($iType === 'list_reply') { $reply = $msg['interactive']['list_reply'] ?? []; } else { $reply = []; } $preview = $iType . ': ' . json_encode($reply); $replyId = $reply['id'] ?? ''; self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply)); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview); self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); if (self::$currentCompany !== null && $replyId !== '') { BotRouter::route(self::$currentCompany, $ctx, $replyId, 'interactive'); } } private static function handleButton(array $msg, array $ctx): void { $text = $msg['button']['text'] ?? ''; $payload = $msg['button']['payload'] ?? ''; $preview = "$text | $payload"; $replyId = $payload ?: $text; self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview); self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); if (self::$currentCompany !== null && $replyId !== '') { BotRouter::route(self::$currentCompany, $ctx, $replyId, 'button'); } } private static function handleReaction(array $msg, array $ctx): void { $emoji = $msg['reaction']['emoji'] ?? ''; $reactTo = $msg['reaction']['message_id'] ?? ''; $preview = "emoji:$emoji replyTo:$reactTo"; self::log('MSG', "[REACTION] {$ctx['from']} ({$ctx['name']}) | emoji=$emoji msg=$reactTo"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'reaction', $preview); self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); } // ─── Estados de mensajes enviados ──────────────────────────────────────── private static function handleStatuses(array $value): void { $statuses = $value['statuses'] ?? []; foreach ($statuses as $s) { $id = $s['id'] ?? ''; $status = $s['status'] ?? ''; // sent | delivered | read | failed $recipient = $s['recipient_id'] ?? ''; $ts = $s['timestamp'] ?? ''; if ($status === 'failed') { $errors = $s['errors'] ?? []; self::log('ERROR', "[STATUS:failed] msg=$id recipient=$recipient errors=" . json_encode($errors)); } else { self::log('INFO', "[STATUS:$status] msg=$id recipient=$recipient ts=$ts"); } self::saveWebhookLog('statuses', $recipient, '', $status, "msg_id:$id"); if (self::$currentCompany !== null) { $statusData = [ 'message_id' => $id, 'status' => $status, 'recipient' => $recipient, 'timestamp' => $ts, 'errors' => $s['errors'] ?? null, ]; CompanyApiClient::forwardStatus(self::$currentCompany, $statusData); } } } // ─── Firma HMAC-SHA256 de Meta ──────────────────────────────────────────── private static function verifySignature(string $rawBody): void { $appSecret = env('WHATSAPP_APP_SECRET', ''); // Si el checkbox "Validar firma HMAC" está desactivado (0), saltar validación $enabled = env('verify_hmac_signature', '1'); if ($enabled === '0') { self::log('INFO', 'Validación HMAC desactivada por configuración.'); return; } if ($appSecret === '') { self::log('WARN', 'WHATSAPP_APP_SECRET no configurado. Saltando verificación de firma.'); return; } $sigHeader = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; if (strpos($sigHeader, 'sha256=') !== 0) { self::respond(401, ['error' => 'Firma ausente']); } $received = substr($sigHeader, 7); $expected = hash_hmac('sha256', $rawBody, $appSecret); if (!hash_equals($expected, $received)) { self::log('WARN', 'Firma HMAC inválida. Posible payload adulterado.'); self::respond(401, ['error' => 'Firma inválida']); } } // ─── Utilidades ─────────────────────────────────────────────────────────── // ─── Guardar en base de datos ───────────────────────────────────────────── private static function saveConversation(array $ctx, string $content, ?string $mediaId = null): void { try { $companyId = self::$currentCompany['id'] ?? null; $stmt = db()->prepare(" INSERT IGNORE INTO conversations (company_id, message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp) VALUES (?, ?, ?, ?, 'inbound', ?, ?, ?, ?) "); $stmt->execute([ $companyId, $ctx['message_id'], $ctx['from'], $ctx['name'], $ctx['type'], mb_substr($content, 0, 1000), $mediaId, $ctx['timestamp'], ]); if ($stmt->rowCount() > 0) { $convId = (int) db()->lastInsertId(); self::saveNotification($convId, $ctx['from'], $content); } } catch (\PDOException $e) { self::log('ERROR', 'DB saveConversation: ' . $e->getMessage()); } } private static function saveNotification(int $referenceId, string $phone, string $preview): void { try { $companyId = self::$currentCompany['id'] ?? null; $stmt = db()->prepare(" INSERT INTO notifications (company_id, type, reference_id, phone_number, message) VALUES (?, 'new_message', ?, ?, ?) "); $stmt->execute([$companyId, $referenceId, $phone, mb_substr($preview, 0, 255)]); } catch (\PDOException $e) { self::log('ERROR', 'DB saveNotification: ' . $e->getMessage()); } } private static function saveWebhookLog( string $field, string $from, string $name, string $type, string $preview ): void { try { $companyId = self::$currentCompany['id'] ?? null; $stmt = db()->prepare(" INSERT INTO webhook_logs (company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload) VALUES (?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([$companyId, $field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]); } catch (\PDOException $e) { self::log('ERROR', 'DB saveWebhookLog: ' . $e->getMessage()); } } /** * Guarda el evento crudo en storage/events/ para auditoría. */ private static function saveRawEvent(string $raw): void { $dir = dirname(__DIR__, 2) . '/storage/events'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $file = $dir . '/' . date('Y-m-d') . '.log'; $line = '[' . date('Y-m-d H:i:s') . '] ' . $raw . PHP_EOL; file_put_contents($file, $line, FILE_APPEND | LOCK_EX); } private static function log(string $level, string $message): void { $dir = dirname(__DIR__, 2) . '/storage/logs'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $file = $dir . '/webhook-' . date('Y-m-d') . '.log'; $line = '[' . date('Y-m-d H:i:s') . "] [$level] $message" . PHP_EOL; file_put_contents($file, $line, FILE_APPEND | LOCK_EX); // También a stderr en desarrollo if (env('APP_ENV', 'production') === 'local') { error_log($line); } } private static function debugLog(string $method, string $data): void { $log = '[' . date('Y-m-d H:i:s') . "] {$method}\n{$data}\n" . str_repeat('-', 60) . "\n"; @file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND); } private static function respond(int $code, array $body): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($body, JSON_UNESCAPED_UNICODE); exit; } }