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:
@@ -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
|
|
||||||
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([
|
WhatsappLog::create([
|
||||||
'alumno_id' => $alumno?->id,
|
|
||||||
'telefono' => $telefono,
|
'telefono' => $telefono,
|
||||||
'tipo' => 'status_' . $estadoMeta,
|
'tipo' => 'recibido',
|
||||||
'mensaje' => "Acuse de {$estadoMeta} para mensaje {$mensajeId}",
|
'mensaje' => $texto,
|
||||||
|
'whatsapp_mensaje_id' => data_get($message, 'id'),
|
||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Actualizar pivot alumno_promocion cuando hay confirmación de entrega o lectura
|
if (preg_match('/\b(STOP|cancelar)\b/i', $texto)) {
|
||||||
if ($mensajeId && in_array($estadoMeta, ['delivered', 'read', 'failed'])) {
|
$whatsapp->procesarOptOut($telefono);
|
||||||
$nuevoStatus = match($estadoMeta) {
|
}
|
||||||
'delivered' => 'entregado',
|
}
|
||||||
'read' => 'leido',
|
|
||||||
'failed' => 'fallido',
|
|
||||||
default => $estadoMeta,
|
|
||||||
};
|
|
||||||
|
|
||||||
DB::table('alumno_promocion')
|
foreach ($statuses as $status) {
|
||||||
->where('whatsapp_mensaje_id', $mensajeId)
|
$mensajeId = data_get($status, 'id');
|
||||||
->update(['status_envio' => $nuevoStatus]);
|
$statusEnvio = data_get($status, 'status');
|
||||||
}
|
|
||||||
|
WhatsappLog::where('whatsapp_mensaje_id', $mensajeId)
|
||||||
|
->update(['status_envio' => $statusEnvio]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user