From f246a709c455625acf323bbfa612d5a397cebf58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Sun, 24 May 2026 22:01:37 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20WhatsApp=20webhook=20=E2=80=94=20reescr?= =?UTF-8?q?itura=20del=20controller=20y=20rutas=20en=20web.php?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifica WhatsAppWebhookController: messages crea WhatsappLog con tipo 'recibido', statuses actualiza el log existente por whatsapp_mensaje_id, opt-out detecta solo STOP/cancelar con regex. Agrega rutas GET/POST /webhook/whatsapp fuera del grupo auth en web.php (POST sin CSRF). Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/WhatsAppWebhookController.php | 153 ++++-------------- routes/web.php | 4 + 2 files changed, 34 insertions(+), 123 deletions(-) diff --git a/app/Http/Controllers/WhatsAppWebhookController.php b/app/Http/Controllers/WhatsAppWebhookController.php index 46f3b244..4beac987 100644 --- a/app/Http/Controllers/WhatsAppWebhookController.php +++ b/app/Http/Controllers/WhatsAppWebhookController.php @@ -2,153 +2,60 @@ namespace App\Http\Controllers; -use App\Events\NuevoMensajeWhatsApp; -use App\Models\Alumno; use App\Models\WhatsappLog; use App\Services\WhatsAppService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; +use Illuminate\Http\Response; class WhatsAppWebhookController extends Controller { - private array $optOutKeywords = ['stop', 'no', 'cancelar', 'detener', 'baja', 'salir']; - - public function verify(Request $request) + public function verify(Request $request): Response { $verifyToken = config('services.whatsapp.verify_token'); if ( - $request->get('hub_mode') === 'subscribe' && - $request->get('hub_verify_token') === $verifyToken + $request->query('hub_mode') === 'subscribe' && + $request->query('hub_verify_token') === $verifyToken ) { - return response($request->get('hub_challenge'), 200); + return response($request->query('hub_challenge'), 200); } return response('Forbidden', 403); } - public function handle(Request $request, WhatsAppService $whatsapp) + public function handle(Request $request, WhatsAppService $whatsapp): JsonResponse { - $payload = $request->all(); + $payload = $request->all(); + $value = data_get($payload, 'entry.0.changes.0.value', []); + $messages = data_get($value, 'messages', []); + $statuses = data_get($value, 'statuses', []); - try { - $entry = $payload['entry'][0] ?? null; - $changes = $entry['changes'][0] ?? null; - $value = $changes['value'] ?? null; - $message = $value['messages'][0] ?? null; + foreach ($messages as $message) { + $telefono = data_get($message, 'from'); + $texto = data_get($message, 'text.body', ''); - // Procesar status updates (entregado/leído/fallido) de Meta - if (!empty($value['statuses'])) { - foreach ($value['statuses'] as $status) { - $this->procesarStatus($status, $payload); - } + WhatsappLog::create([ + 'telefono' => $telefono, + 'tipo' => 'recibido', + 'mensaje' => $texto, + 'whatsapp_mensaje_id' => data_get($message, 'id'), + 'payload' => $payload, + ]); + + if (preg_match('/\b(STOP|cancelar)\b/i', $texto)) { + $whatsapp->procesarOptOut($telefono); } + } - if (!$message) { - return response()->json(['status' => 'ok']); - } + foreach ($statuses as $status) { + $mensajeId = data_get($status, 'id'); + $statusEnvio = data_get($status, 'status'); - $telefono = $message['from'] ?? null; - $tipo = $message['type'] ?? 'text'; - $alumno = $this->alumnoPorTelefono($telefono); - $alumnoId = $alumno?->id; - - $logData = [ - 'alumno_id' => $alumnoId, - 'telefono' => $telefono, - 'tipo' => 'recibido', - 'payload' => $payload, - ]; - - if ($tipo === 'text') { - $texto = $message['text']['body'] ?? ''; - $logData['mensaje'] = $texto; - $logData['media_type'] = null; - - if ($this->esOptOut($texto)) { - $whatsapp->procesarOptOut($telefono); - $logData['tipo'] = 'opt_out'; - } - } elseif (in_array($tipo, ['image', 'video', 'audio', 'document'])) { - $mediaId = $message[$tipo]['id'] ?? null; - - if ($mediaId) { - $ruta = $whatsapp->descargarMedia($mediaId); - $logData['mensaje'] = $ruta; - $logData['media_type'] = $tipo; - } - } - - $log = WhatsappLog::create($logData); - - if ($alumnoId) { - broadcast(new NuevoMensajeWhatsApp($log)); - - // Marcar como no leído para el badge - WhatsappLog::where('id', $log->id)->update(['leido' => false]); - } - } catch (\Throwable $e) { - Log::error('WhatsApp webhook error', ['error' => $e->getMessage(), 'payload' => $payload]); + WhatsappLog::where('whatsapp_mensaje_id', $mensajeId) + ->update(['status_envio' => $statusEnvio]); } return response()->json(['status' => 'ok']); } - - private function alumnoPorTelefono(?string $telefono): ?Alumno - { - if (!$telefono) { - return null; - } - - $limpio = preg_replace('/\D/', '', $telefono); - $sinCodigo = strlen($limpio) === 12 ? substr($limpio, 2) : $limpio; - - return Alumno::whereHas('alumnos', function ($q) use ($limpio, $sinCodigo) { - $q->where('telefono', $limpio)->orWhere('telefono', $sinCodigo); - })->first(); - } - - private function esOptOut(string $texto): bool - { - $normalizado = strtolower(trim($texto)); - - foreach ($this->optOutKeywords as $keyword) { - if ($normalizado === $keyword || str_contains($normalizado, $keyword)) { - return true; - } - } - - return false; - } - - private function procesarStatus(array $status, array $payload): void - { - $mensajeId = $status['id'] ?? null; - $estadoMeta = $status['status'] ?? null; // sent, delivered, read, failed - $telefono = $status['recipient_id'] ?? null; - $alumno = $this->alumnoPorTelefono($telefono); - - WhatsappLog::create([ - 'alumno_id' => $alumno?->id, - 'telefono' => $telefono, - 'tipo' => 'status_' . $estadoMeta, - 'mensaje' => "Acuse de {$estadoMeta} para mensaje {$mensajeId}", - 'payload' => $payload, - ]); - - // Actualizar pivot alumno_promocion cuando hay confirmación de entrega o lectura - if ($mensajeId && in_array($estadoMeta, ['delivered', 'read', 'failed'])) { - $nuevoStatus = match($estadoMeta) { - 'delivered' => 'entregado', - 'read' => 'leido', - 'failed' => 'fallido', - default => $estadoMeta, - }; - - DB::table('alumno_promocion') - ->where('whatsapp_mensaje_id', $mensajeId) - ->update(['status_envio' => $nuevoStatus]); - } - } } diff --git a/routes/web.php b/routes/web.php index ede7c225..0b9395ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,10 @@ Route::get('/',function(){return view('auth.login');}); Route::get('/prueba',function(){return view('prueba.index');}); Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']); +Route::get('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'verify']); +Route::post('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'handle']) + ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); + Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified', ])->group(function () {