feat: WhatsApp webhook — reescritura del controller y rutas en web.php

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 22:01:37 -06:00
parent 61b18df8e8
commit f246a709c4
2 changed files with 34 additions and 123 deletions
@@ -2,153 +2,60 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Events\NuevoMensajeWhatsApp;
use App\Models\Alumno;
use App\Models\WhatsappLog; use App\Models\WhatsappLog;
use App\Services\WhatsAppService; use App\Services\WhatsAppService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class WhatsAppWebhookController extends Controller class WhatsAppWebhookController extends Controller
{ {
private array $optOutKeywords = ['stop', 'no', 'cancelar', 'detener', 'baja', 'salir']; public function verify(Request $request): Response
public function verify(Request $request)
{ {
$verifyToken = config('services.whatsapp.verify_token'); $verifyToken = config('services.whatsapp.verify_token');
if ( if (
$request->get('hub_mode') === 'subscribe' && $request->query('hub_mode') === 'subscribe' &&
$request->get('hub_verify_token') === $verifyToken $request->query('hub_verify_token') === $verifyToken
) { ) {
return response($request->get('hub_challenge'), 200); return response($request->query('hub_challenge'), 200);
} }
return response('Forbidden', 403); 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 { foreach ($messages as $message) {
$entry = $payload['entry'][0] ?? null; $telefono = data_get($message, 'from');
$changes = $entry['changes'][0] ?? null; $texto = data_get($message, 'text.body', '');
$value = $changes['value'] ?? null;
$message = $value['messages'][0] ?? null;
// Procesar status updates (entregado/leído/fallido) de Meta WhatsappLog::create([
if (!empty($value['statuses'])) { 'telefono' => $telefono,
foreach ($value['statuses'] as $status) { 'tipo' => 'recibido',
$this->procesarStatus($status, $payload); '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) { foreach ($statuses as $status) {
return response()->json(['status' => 'ok']); $mensajeId = data_get($status, 'id');
} $statusEnvio = data_get($status, 'status');
$telefono = $message['from'] ?? null; WhatsappLog::where('whatsapp_mensaje_id', $mensajeId)
$tipo = $message['type'] ?? 'text'; ->update(['status_envio' => $statusEnvio]);
$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']); 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]);
}
}
} }
+4
View File
@@ -54,6 +54,10 @@ Route::get('/',function(){return view('auth.login');});
Route::get('/prueba',function(){return view('prueba.index');}); Route::get('/prueba',function(){return view('prueba.index');});
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']); 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', Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
])->group(function () { ])->group(function () {