get('hub_mode') === 'subscribe' && $request->get('hub_verify_token') === $verifyToken ) { return response($request->get('hub_challenge'), 200); } return response('Forbidden', 403); } public function handle(Request $request, WhatsAppService $whatsapp) { $payload = $request->all(); try { $entry = $payload['entry'][0] ?? null; $changes = $entry['changes'][0] ?? null; $value = $changes['value'] ?? null; $message = $value['messages'][0] ?? null; // Procesar status updates (entregado/leído/fallido) de Meta if (!empty($value['statuses'])) { foreach ($value['statuses'] as $status) { $this->procesarStatus($status, $payload); } } if (!$message) { return response()->json(['status' => 'ok']); } $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]); } 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]); } } }