244 lines
9.7 KiB
PHP
244 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\WhatsappConversation;
|
|
use App\Models\WhatsappSystemConfig;
|
|
use App\Models\WhatsappUser;
|
|
use App\Models\WhatsappWebhookLog;
|
|
use App\Services\WhatsappBotService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class WhatsappWebhookController extends Controller
|
|
{
|
|
// ──────────────────────────────────────────────────────────
|
|
// GET /webhooks — Meta verifica el webhook al registrarlo
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
public function verify(Request $request): Response
|
|
{
|
|
// PHP convierte dots a underscores en $_GET (hub.mode → hub_mode)
|
|
$mode = $request->query('hub_mode');
|
|
$token = $request->query('hub_verify_token');
|
|
$challenge = $request->query('hub_challenge');
|
|
|
|
$storedToken = WhatsappSystemConfig::get('webhook_verify_token');
|
|
|
|
if ($mode === 'subscribe' && $token === $storedToken) {
|
|
return response((string) $challenge, 200)
|
|
->header('Content-Type', 'text/plain');
|
|
}
|
|
|
|
Log::warning('[WhatsApp Webhook] Verificación fallida.', [
|
|
'mode' => $mode,
|
|
'token' => $token,
|
|
]);
|
|
|
|
return response('Forbidden', 403);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// POST /webhooks — Meta envía mensajes y actualizaciones
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
public function handle(Request $request)
|
|
{
|
|
$payload = $request->all();
|
|
|
|
// Loguear el payload entrante antes de procesar
|
|
WhatsappWebhookLog::create([
|
|
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
|
'response' => null,
|
|
'status_code' => 200,
|
|
]);
|
|
|
|
// Meta requiere respuesta 200 inmediata; el procesamiento va dentro de try-catch
|
|
try {
|
|
$this->processPayload($payload);
|
|
} catch (\Throwable $e) {
|
|
Log::error('[WhatsApp Webhook] Error al procesar payload: ' . $e->getMessage(), [
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
}
|
|
|
|
return response()->json(['status' => 'ok']);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Extracción y enrutamiento del payload
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
private function processPayload(array $payload): void
|
|
{
|
|
foreach ($payload['entry'] ?? [] as $entry) {
|
|
foreach ($entry['changes'] ?? [] as $change) {
|
|
if (($change['field'] ?? '') !== 'messages') {
|
|
continue;
|
|
}
|
|
|
|
$value = $change['value'] ?? [];
|
|
|
|
// Actualizaciones de estado de mensajes enviados (delivered, read, failed)
|
|
foreach ($value['statuses'] ?? [] as $status) {
|
|
$this->handleStatusUpdate($status);
|
|
}
|
|
|
|
// Mensajes entrantes de usuarios
|
|
$contacts = $value['contacts'] ?? [];
|
|
$contact = $contacts[0] ?? [];
|
|
|
|
foreach ($value['messages'] ?? [] as $message) {
|
|
$this->handleMessage($message, $contact);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Actualización de estado de un mensaje saliente
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
private function handleStatusUpdate(array $status): void
|
|
{
|
|
$messageId = $status['id'] ?? null;
|
|
$newStatus = $status['status'] ?? null; // delivered | read | failed
|
|
|
|
if ($messageId && $newStatus) {
|
|
WhatsappConversation::where('message_id', $messageId)
|
|
->update(['status' => $newStatus]);
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Procesamiento de un mensaje entrante
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
private function handleMessage(array $message, array $contact): void
|
|
{
|
|
$messageId = $message['id'] ?? null;
|
|
$from = $message['from'] ?? null; // número en formato internacional
|
|
$type = $message['type'] ?? 'text';
|
|
$nombre = trim($contact['profile']['name'] ?? '');
|
|
|
|
if (! $from || ! $messageId) {
|
|
return;
|
|
}
|
|
|
|
// Deduplicación: si ya procesamos este message_id, descartarlo
|
|
if (WhatsappConversation::where('message_id', $messageId)->exists()) {
|
|
return;
|
|
}
|
|
|
|
// Extraer contenido según el tipo de mensaje
|
|
[$texto, $mediaId, $filename, $mimeType] = $this->extractContent($message, $type);
|
|
|
|
// Obtener o crear el usuario
|
|
$user = WhatsappUser::firstOrCreate(
|
|
['phone_number' => $from],
|
|
['name' => $nombre ?: $from, 'status' => 'active']
|
|
);
|
|
|
|
if ($nombre && $user->name !== $nombre) {
|
|
$user->update(['name' => $nombre]);
|
|
}
|
|
|
|
// Guardar mensaje entrante en el historial
|
|
WhatsappConversation::create([
|
|
'user_id' => $user->id,
|
|
'message_id' => $messageId,
|
|
'reply_to_message_id' => $message['context']['id'] ?? null,
|
|
'direction' => 'incoming',
|
|
'message_type' => $type,
|
|
'content' => $texto,
|
|
'whatsapp_media_id' => $mediaId,
|
|
'filename' => $filename,
|
|
'mime_type' => $mimeType,
|
|
'status' => 'received',
|
|
'is_read' => false,
|
|
]);
|
|
|
|
$botService = new WhatsappBotService();
|
|
|
|
// Confirmar lectura a WhatsApp
|
|
$botService->markRead($messageId);
|
|
|
|
// Procesar con el motor del bot
|
|
$botService->handle($user, $texto ?? '', $type);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Extracción de contenido por tipo de mensaje
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
private function extractContent(array $message, string $type): array
|
|
{
|
|
$texto = null;
|
|
$mediaId = null;
|
|
$filename = null;
|
|
$mime = null;
|
|
|
|
switch ($type) {
|
|
case 'text':
|
|
$texto = $message['text']['body'] ?? '';
|
|
break;
|
|
|
|
case 'interactive':
|
|
$interactive = $message['interactive'] ?? [];
|
|
$subtype = $interactive['type'] ?? '';
|
|
|
|
if ($subtype === 'button_reply') {
|
|
$titulo = $interactive['button_reply']['title'] ?? '';
|
|
$texto = $this->normalizeInteractiveTitle($titulo);
|
|
} elseif ($subtype === 'list_reply') {
|
|
$titulo = $interactive['list_reply']['title'] ?? '';
|
|
$texto = $this->normalizeInteractiveTitle($titulo);
|
|
}
|
|
break;
|
|
|
|
case 'image':
|
|
case 'video':
|
|
case 'audio':
|
|
case 'sticker':
|
|
$block = $message[$type] ?? [];
|
|
$mediaId = $block['id'] ?? null;
|
|
$mime = $block['mime_type'] ?? null;
|
|
$texto = $block['caption'] ?? null;
|
|
break;
|
|
|
|
case 'document':
|
|
$block = $message['document'] ?? [];
|
|
$mediaId = $block['id'] ?? null;
|
|
$filename = $block['filename'] ?? null;
|
|
$mime = $block['mime_type'] ?? null;
|
|
$texto = $block['caption'] ?? null;
|
|
break;
|
|
|
|
case 'location':
|
|
$loc = $message['location'] ?? [];
|
|
$texto = 'Ubicación: ' . ($loc['latitude'] ?? '') . ',' . ($loc['longitude'] ?? '');
|
|
break;
|
|
|
|
case 'reaction':
|
|
$texto = $message['reaction']['emoji'] ?? '';
|
|
break;
|
|
}
|
|
|
|
return [$texto, $mediaId, $filename, $mime];
|
|
}
|
|
|
|
/**
|
|
* Normaliza respuestas interactivas al número de opción si el título empieza con dígito.
|
|
* Ejemplo: "1. Ver catálogo" → "1"
|
|
*/
|
|
private function normalizeInteractiveTitle(string $titulo): string
|
|
{
|
|
if (preg_match('/^(\d+)[\.\)\s]/', $titulo, $m)) {
|
|
return $m[1];
|
|
}
|
|
|
|
return $titulo;
|
|
}
|
|
}
|