up
This commit is contained in:
@@ -0,0 +1,243 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\WhatsappConversation;
|
||||||
|
use App\Models\WhatsappMenu;
|
||||||
|
use App\Models\WhatsappSystemConfig;
|
||||||
|
use App\Models\WhatsappUser;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class WhatsappBotService
|
||||||
|
{
|
||||||
|
private string $token;
|
||||||
|
private string $phoneNumberId;
|
||||||
|
private string $apiUrl;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->token = WhatsappSystemConfig::get('whatsapp_token');
|
||||||
|
$this->phoneNumberId = WhatsappSystemConfig::get('phone_number_id');
|
||||||
|
$this->apiUrl = WhatsappSystemConfig::get('whatsapp_api_url', 'https://graph.facebook.com/v22.0/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Punto de entrada principal
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function handle(WhatsappUser $user, string $texto, string $tipo = 'text'): void
|
||||||
|
{
|
||||||
|
// Usuario bloqueado → ignorar
|
||||||
|
if ($user->status === 'blocked') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atendido por asesor → solo almacenar, no responder
|
||||||
|
if ($user->in_service) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot deshabilitado para este usuario → ignorar
|
||||||
|
if (! $user->bot_enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot deshabilitado globalmente → ignorar
|
||||||
|
if (WhatsappSystemConfig::get('bot_enabled', '1') !== '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medios (imagen, video, audio, documento, etc.) → aviso y salir
|
||||||
|
if (! in_array($tipo, ['text', 'interactive'])) {
|
||||||
|
$msg = 'Recibimos tu ' . $tipo . '. Un asesor te contactará pronto.';
|
||||||
|
$this->saveOutgoing($user->id, $msg);
|
||||||
|
$this->sendText($user->phone_number, $msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$texto = trim($texto);
|
||||||
|
|
||||||
|
// ── Comandos especiales ──────────────────────────────
|
||||||
|
if ($this->isMenuCommand($texto)) {
|
||||||
|
$this->enviarMenuPrincipal($user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isBackCommand($texto)) {
|
||||||
|
$this->irAtras($user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isAdvisorCommand($texto)) {
|
||||||
|
$this->transferirAsesor($user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Primera visita → bienvenida ──────────────────────
|
||||||
|
if (! $user->welcome_sent_at) {
|
||||||
|
$this->enviarBienvenida($user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navegación por menú ──────────────────────────────
|
||||||
|
$this->procesarSeleccion($user, $texto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Comandos
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function isMenuCommand(string $texto): bool
|
||||||
|
{
|
||||||
|
return in_array(mb_strtolower($texto), ['menu', 'menú', 'inicio', 'start', 'hola', 'hi']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBackCommand(string $texto): bool
|
||||||
|
{
|
||||||
|
return in_array(mb_strtolower($texto), ['atras', 'atrás', 'volver', 'back', 'regresar']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAdvisorCommand(string $texto): bool
|
||||||
|
{
|
||||||
|
return in_array(mb_strtolower($texto), ['asesor', 'agente', 'humano', 'ayuda']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Flujos de respuesta
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function enviarBienvenida(WhatsappUser $user): void
|
||||||
|
{
|
||||||
|
$msg = WhatsappSystemConfig::get(
|
||||||
|
'welcome_message',
|
||||||
|
'¡Hola! Bienvenido. Escribe *menu* para ver las opciones disponibles.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->update(['welcome_sent_at' => now(), 'current_menu_id' => null]);
|
||||||
|
$this->saveOutgoing($user->id, $msg);
|
||||||
|
$this->sendText($user->phone_number, $msg);
|
||||||
|
|
||||||
|
// Mostrar menú principal después del saludo
|
||||||
|
$this->enviarMenuPrincipal($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enviarMenuPrincipal(WhatsappUser $user): void
|
||||||
|
{
|
||||||
|
$menu = WhatsappMenu::where('is_main', 1)->where('is_active', 1)->first();
|
||||||
|
|
||||||
|
if (! $menu) {
|
||||||
|
$msg = WhatsappSystemConfig::get('default_no_match', 'No hay opciones disponibles en este momento.');
|
||||||
|
$this->saveOutgoing($user->id, $msg);
|
||||||
|
$this->sendText($user->phone_number, $msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->update(['current_menu_id' => $menu->id]);
|
||||||
|
$texto = $this->formatMenu($menu);
|
||||||
|
$this->saveOutgoing($user->id, $texto);
|
||||||
|
$this->sendText($user->phone_number, $texto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function irAtras(WhatsappUser $user): void
|
||||||
|
{
|
||||||
|
$menuActual = $user->current_menu_id
|
||||||
|
? WhatsappMenu::find($user->current_menu_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$menuPadre = ($menuActual && $menuActual->parent_id)
|
||||||
|
? WhatsappMenu::find($menuActual->parent_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($menuPadre && $menuPadre->is_active) {
|
||||||
|
$user->update(['current_menu_id' => $menuPadre->id]);
|
||||||
|
$texto = $this->formatMenu($menuPadre);
|
||||||
|
$this->saveOutgoing($user->id, $texto);
|
||||||
|
$this->sendText($user->phone_number, $texto);
|
||||||
|
} else {
|
||||||
|
$this->enviarMenuPrincipal($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function transferirAsesor(WhatsappUser $user): void
|
||||||
|
{
|
||||||
|
$msg = WhatsappSystemConfig::get(
|
||||||
|
'advisor_message',
|
||||||
|
'Un asesor se comunicará contigo en breve. Por favor espera.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->update([
|
||||||
|
'advisor_requested' => true,
|
||||||
|
'in_service' => false,
|
||||||
|
'current_menu_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->saveOutgoing($user->id, $msg);
|
||||||
|
$this->sendText($user->phone_number, $msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function procesarSeleccion(WhatsappUser $user, string $texto): void
|
||||||
|
{
|
||||||
|
if (! $user->current_menu_id) {
|
||||||
|
$this->enviarMenuPrincipal($user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$menu = WhatsappMenu::find($user->current_menu_id);
|
||||||
|
|
||||||
|
if (! $menu || ! $menu->is_active) {
|
||||||
|
$this->enviarMenuPrincipal($user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo aceptar selección numérica
|
||||||
|
$numero = is_numeric($texto) ? (int) $texto : null;
|
||||||
|
$opcion = $numero
|
||||||
|
? $menu->options()->where('option_number', $numero)->where('is_active', 1)->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $opcion) {
|
||||||
|
$noMatch = WhatsappSystemConfig::get('default_no_match', 'Opción no reconocida. Escribe el número de la opción deseada.');
|
||||||
|
$fullMsg = $noMatch . "\n\n" . $this->formatMenu($menu);
|
||||||
|
$this->saveOutgoing($user->id, $fullMsg);
|
||||||
|
$this->sendText($user->phone_number, $fullMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($opcion->action_type) {
|
||||||
|
'submenu' => $this->irASubmenu($user, $opcion),
|
||||||
|
'message' => $this->responderMensaje($user, $opcion),
|
||||||
|
'advisor' => $this->transferirAsesor($user),
|
||||||
|
'template' => $this->enviarPlantilla($user, $opcion),
|
||||||
|
default => $this->responderMensaje($user, $opcion),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function irASubmenu(WhatsappUser $user, object $opcion): void
|
||||||
|
{
|
||||||
|
$subMenu = WhatsappMenu::find($opcion->action_value);
|
||||||
|
|
||||||
|
if (! $subMenu || ! $subMenu->is_active) {
|
||||||
|
$msg = WhatsappSystemConfig::get('default_no_match', 'Opción no disponible en este momento.');
|
||||||
|
$this->saveOutgoing($user->id, $msg);
|
||||||
|
$this->sendText($user->phone_number, $msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->update(['current_menu_id' => $subMenu->id]);
|
||||||
|
$texto = $this->formatMenu($subMenu);
|
||||||
|
$this->saveOutgoing($user->id, $texto);
|
||||||
|
$this->sendText($user->phone_number, $texto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function responderMensaje(WhatsappUser $user, object $opcion): void
|
||||||
|
{
|
||||||
|
$texto = (string) ($opcion->action_value ?? '');
|
||||||
|
$this->saveOutgoing($user->id, $texto);
|
||||||
|
$this->sendText($user->phone_number, $texto);
|
||||||
|
|
||||||
|
// Volver al menú principal tras la respuesta
|
||||||
|
$menuPrincipal = WhatsappMenu::where('is_main', 1)->where('is_active', 1)->first();
|
||||||
|
if ($menuPrincipal) {
|
||||||
|
$user->update(['current_menu_id' => $menuPrincipal->id]);
|
||||||
|
$textoMenu = $this->formatMenu($menuPrincipal);
|
||||||
|
$this->saveOutgoing($user->id, $textoMenu);
|
||||||
|
$this->sendText($user->phone_number, $textoMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enviarPlantilla(WhatsappUser $user, object $opcion): void
|
||||||
|
{
|
||||||
|
$templateName = (string) ($opcion->action_value ?? '');
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'to' => $user->phone_number,
|
||||||
|
'type' => 'template',
|
||||||
|
'template' => [
|
||||||
|
'name' => $templateName,
|
||||||
|
'language' => ['code' => 'es'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->saveOutgoing($user->id, "[Plantilla: {$templateName}]");
|
||||||
|
$this->callApi('messages', $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Helpers de formato
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function formatMenu(WhatsappMenu $menu): string
|
||||||
|
{
|
||||||
|
$lines = [$menu->message];
|
||||||
|
|
||||||
|
foreach ($menu->options()->where('is_active', 1)->get() as $op) {
|
||||||
|
$lines[] = "{$op->option_number}. {$op->title}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Envío a la API de WhatsApp Cloud
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function sendText(string $to, string $body): bool
|
||||||
|
{
|
||||||
|
return $this->callApi('messages', [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'to' => $to,
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => ['body' => $body, 'preview_url' => false],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markRead(string $messageId): void
|
||||||
|
{
|
||||||
|
$this->callApi('messages', [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'status' => 'read',
|
||||||
|
'message_id' => $messageId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callApi(string $endpoint, array $payload): bool
|
||||||
|
{
|
||||||
|
if (! $this->token || ! $this->phoneNumberId) {
|
||||||
|
Log::warning('[WhatsApp] Token o phone_number_id no configurados.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withToken($this->token)
|
||||||
|
->timeout(10)
|
||||||
|
->post("{$this->apiUrl}{$this->phoneNumberId}/{$endpoint}", $payload);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('[WhatsApp] API error: ' . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('[WhatsApp] Excepción al llamar API: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveOutgoing(int $userId, string $content): void
|
||||||
|
{
|
||||||
|
WhatsappConversation::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'direction' => 'outgoing',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'content' => $content,
|
||||||
|
'status' => 'sent',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Módulo Chat — Crear tablas en PostgreSQL
|
||||||
|
-- Ejecutar en el servidor con:
|
||||||
|
-- psql -U <usuario> -d <basedatos> -f chat_tables.sql
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
config_key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
config_value TEXT,
|
||||||
|
description VARCHAR(255),
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_contacts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
canal VARCHAR(20) NOT NULL,
|
||||||
|
canal_id VARCHAR(255) NOT NULL,
|
||||||
|
nombre VARCHAR(255),
|
||||||
|
telefono VARCHAR(255),
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE (canal, canal_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
contact_id BIGINT NOT NULL REFERENCES chat_contacts(id) ON DELETE CASCADE,
|
||||||
|
canal VARCHAR(20) NOT NULL,
|
||||||
|
estado VARCHAR(20) NOT NULL DEFAULT 'bot',
|
||||||
|
menu_actual_id INTEGER,
|
||||||
|
ultimo_mensaje_at TIMESTAMP NULL,
|
||||||
|
mensajes_no_leidos INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
conversation_id BIGINT NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE,
|
||||||
|
tipo VARCHAR(20) NOT NULL,
|
||||||
|
contenido TEXT NOT NULL,
|
||||||
|
leido BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_menus (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
nombre VARCHAR(255) NOT NULL,
|
||||||
|
mensaje TEXT NOT NULL,
|
||||||
|
es_raiz BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
activo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_menu_options (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
menu_id BIGINT NOT NULL REFERENCES chat_menus(id) ON DELETE CASCADE,
|
||||||
|
clave VARCHAR(20) NOT NULL,
|
||||||
|
etiqueta VARCHAR(255) NOT NULL,
|
||||||
|
accion VARCHAR(30) NOT NULL,
|
||||||
|
menu_destino_id BIGINT,
|
||||||
|
respuesta_texto TEXT,
|
||||||
|
orden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Registrar la migración en la tabla de Laravel para que no se corra de nuevo
|
||||||
|
INSERT INTO migrations (migration, batch)
|
||||||
|
SELECT '2026_04_30_000001_create_chat_tables', COALESCE((SELECT MAX(batch) FROM migrations), 0) + 1
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM migrations WHERE migration = '2026_04_30_000001_create_chat_tables'
|
||||||
|
);
|
||||||
+4
-1
@@ -4,6 +4,7 @@ use App\Http\Controllers\ChatController;
|
|||||||
use App\Http\Controllers\MenuController;
|
use App\Http\Controllers\MenuController;
|
||||||
use App\Http\Controllers\TelegramWebhookController;
|
use App\Http\Controllers\TelegramWebhookController;
|
||||||
use App\Http\Controllers\WhatsappController;
|
use App\Http\Controllers\WhatsappController;
|
||||||
|
use App\Http\Controllers\WhatsappWebhookController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
@@ -25,7 +26,9 @@ Route::get('/wompi_pagos', [MenuController::class, 'wompi_pagos'])->name('wompi
|
|||||||
Route::any('/wompi_hook', [MenuController::class, 'wompi_hook'])->name('wompi_hook');
|
Route::any('/wompi_hook', [MenuController::class, 'wompi_hook'])->name('wompi_hook');
|
||||||
Route::get('/compras', [MenuController::class, 'compras'])->name('compras');
|
Route::get('/compras', [MenuController::class, 'compras'])->name('compras');
|
||||||
Route::get('/pay/{estado}', [MenuController::class, 'pay'])->name('pay');
|
Route::get('/pay/{estado}', [MenuController::class, 'pay'])->name('pay');
|
||||||
Route::post('/webhooks', [MenuController::class, 'showwebhooks']);
|
Route::get('/webhooks', [WhatsappWebhookController::class, 'verify']);
|
||||||
|
Route::post('/webhooks', [WhatsappWebhookController::class, 'handle'])
|
||||||
|
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
Route::get('/credenciales', [MenuController::class, 'showcredenciales'])->name('credenciales');
|
Route::get('/credenciales', [MenuController::class, 'showcredenciales'])->name('credenciales');
|
||||||
Route::get('/oferta', [MenuController::class, 'showoferta'])->name('oferta');
|
Route::get('/oferta', [MenuController::class, 'showoferta'])->name('oferta');
|
||||||
Route::get('/single', [MenuController::class, 'showsingle'])->name('single');
|
Route::get('/single', [MenuController::class, 'showsingle'])->name('single');
|
||||||
|
|||||||
Reference in New Issue
Block a user