diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php new file mode 100644 index 0000000..52fdb9e --- /dev/null +++ b/app/Http/Controllers/ChatController.php @@ -0,0 +1,26 @@ +all(); + + // Solo procesar mensajes de texto + if (! isset($update['message']['text'])) { + return response()->json(['ok' => true]); + } + + $chatId = (string) $update['message']['chat']['id']; + $text = $update['message']['text']; + $nombre = trim( + ($update['message']['from']['first_name'] ?? '') . ' ' . + ($update['message']['from']['last_name'] ?? '') + ); + + $engine = new ChatBotEngine(); + $replies = $engine->handle('telegram', $chatId, $text, $nombre); + + foreach ($replies as $reply) { + $engine->enviarTelegram($chatId, $reply); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Livewire/Chat/PublicChat.php b/app/Http/Livewire/Chat/PublicChat.php new file mode 100644 index 0000000..a10e980 --- /dev/null +++ b/app/Http/Livewire/Chat/PublicChat.php @@ -0,0 +1,100 @@ +validate([ + 'telefono' => 'required|min:7|max:20', + 'nombre' => 'nullable|max:80', + ]); + + $contact = ChatContact::firstOrCreate( + ['canal' => 'web', 'canal_id' => $this->telefono], + ['nombre' => $this->nombre ?: $this->telefono, 'telefono' => $this->telefono] + ); + + if ($this->nombre && $contact->nombre !== $this->nombre) { + $contact->update(['nombre' => $this->nombre]); + } + + $conv = $contact->activeConversation(); + + if (! $conv) { + // Primera vez β€” el engine inicia el flujo con "hola" + $engine = new ChatBotEngine(); + $engine->handle('web', $this->telefono, 'hola', $contact->nombre); + $contact->refresh(); + $conv = $contact->activeConversation(); + } + + if ($conv) { + $this->convId = $conv->id; + $this->estadoConv = $conv->estado; + $this->cargarMensajes(); + } + + $this->paso = 'chat'; + } + + public function enviar() + { + if (! $this->convId || trim($this->input) === '') { + return; + } + + $texto = trim($this->input); + $this->input = ''; + + $engine = new ChatBotEngine(); + $engine->handle('web', $this->telefono, $texto, $this->nombre ?: $this->telefono); + + $conv = ChatConversation::find($this->convId); + if ($conv) { + $this->estadoConv = $conv->fresh()->estado; + } + + $this->cargarMensajes(); + // scroll ya se dispara desde cargarMensajes() + } + + public function cargarMensajes() + { + if (! $this->convId) { + return; + } + + $this->mensajes = ChatMessage::where('conversation_id', $this->convId) + ->orderBy('id') + ->get() + ->map(fn($m) => [ + 'tipo' => $m->tipo, + 'contenido' => $m->contenido, + 'created_at' => $m->created_at->format('H:i'), + ]) + ->toArray(); + + $this->dispatchBrowserEvent('scroll-chat'); + } + + public function render() + { + return view('livewire.chat.public-chat'); + } +} diff --git a/app/Http/Livewire/Chat/ShowConfiguracionChat.php b/app/Http/Livewire/Chat/ShowConfiguracionChat.php new file mode 100644 index 0000000..8b747e2 --- /dev/null +++ b/app/Http/Livewire/Chat/ShowConfiguracionChat.php @@ -0,0 +1,80 @@ +telegram_token = ChatConfig::get('telegram_token', ''); + $this->mensaje_bienvenida = ChatConfig::get('mensaje_bienvenida', 'Hola πŸ‘‹ Bienvenido. Escribe tu consulta.'); + $this->mensaje_transferencia = ChatConfig::get('mensaje_transferencia', 'Un agente se comunicarΓ‘ contigo en breve. Por favor espera.'); + } + + public function guardar(): void + { + $this->validate([ + 'mensaje_bienvenida' => 'required|string|max:500', + 'mensaje_transferencia' => 'required|string|max:500', + ]); + + ChatConfig::set('telegram_token', $this->telegram_token); + ChatConfig::set('mensaje_bienvenida', $this->mensaje_bienvenida); + ChatConfig::set('mensaje_transferencia', $this->mensaje_transferencia); + + $this->alert('success', 'ConfiguraciΓ³n guardada correctamente.'); + } + + public function registrarWebhook(): void + { + $token = trim($this->telegram_token); + + if (! $token) { + $this->webhookResult = '⚠️ El token de Telegram estΓ‘ vacΓ­o.'; + return; + } + + // Guardar el token antes de registrar + ChatConfig::set('telegram_token', $token); + + $webhookUrl = url('/chat/webhook/telegram'); + $apiUrl = "https://api.telegram.org/bot{$token}/setWebhook"; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", + 'content' => http_build_query(['url' => $webhookUrl]), + 'timeout' => 10, + ], + ]); + + $result = @file_get_contents($apiUrl, false, $context); + + if ($result === false) { + $this->webhookResult = '❌ Error de conexiΓ³n con la API de Telegram.'; + return; + } + + $data = json_decode($result, true); + $this->webhookResult = ($data['ok'] ?? false) + ? 'βœ… Webhook registrado correctamente: ' . ($data['description'] ?? 'OK') + : '❌ Error: ' . ($data['description'] ?? 'respuesta desconocida'); + } + + public function render() + { + return view('livewire.chat.show-configuracion-chat'); + } +} diff --git a/app/Http/Livewire/Chat/ShowConversaciones.php b/app/Http/Livewire/Chat/ShowConversaciones.php new file mode 100644 index 0000000..d82a948 --- /dev/null +++ b/app/Http/Livewire/Chat/ShowConversaciones.php @@ -0,0 +1,139 @@ +convSelecId = $convId; + $this->replyText = ''; + $this->cargarMensajes(); + + // Marcar mensajes entrantes como leΓ­dos + ChatMessage::where('conversation_id', $convId) + ->where('tipo', 'usuario') + ->where('leido', false) + ->update(['leido' => true]); + + ChatConversation::where('id', $convId) + ->update(['mensajes_no_leidos' => 0]); + } + + public function cargarMensajes(): void + { + if (! $this->convSelecId) { + $this->mensajes = []; + return; + } + + $this->mensajes = ChatMessage::where('conversation_id', $this->convSelecId) + ->orderBy('id') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'tipo' => $m->tipo, + 'contenido' => $m->contenido, + 'created_at' => $m->created_at->format('d/m H:i'), + ]) + ->toArray(); + + $this->dispatchBrowserEvent('scroll-admin'); + } + + public function sendReply(): void + { + $this->validate(['replyText' => 'required|string|max:4096']); + + if (! $this->convSelecId) { + return; + } + + $conv = ChatConversation::with('contact')->find($this->convSelecId); + if (! $conv) { + return; + } + + ChatMessage::create([ + 'conversation_id' => $this->convSelecId, + 'tipo' => 'agente', + 'contenido' => $this->replyText, + 'leido' => true, + ]); + + $conv->update(['ultimo_mensaje_at' => now()]); + + // Enviar via Telegram si el canal es telegram + if ($conv->canal === 'telegram') { + $engine = new ChatBotEngine(); + $engine->enviarTelegram($conv->contact->canal_id, $this->replyText); + } + + $this->replyText = ''; + $this->cargarMensajes(); + } + + public function cerrarConv(): void + { + if (! $this->convSelecId) { + return; + } + + ChatConversation::where('id', $this->convSelecId) + ->update(['estado' => 'cerrada']); + + $this->cargarMensajes(); + } + + public function devolverAlBot(): void + { + if (! $this->convSelecId) { + return; + } + + ChatConversation::where('id', $this->convSelecId) + ->update(['estado' => 'bot', 'menu_actual_id' => null]); + } + + public function tomarControl(): void + { + if (! $this->convSelecId) { + return; + } + + ChatConversation::where('id', $this->convSelecId) + ->update(['estado' => 'agente']); + } + + public function render() + { + $conversaciones = ChatConversation::with('contact') + ->when($this->filtroEstado, fn($q) => $q->where('estado', $this->filtroEstado)) + ->when($this->busqueda, function ($q) { + $q->whereHas('contact', function ($s) { + $s->where('nombre', 'like', "%{$this->busqueda}%") + ->orWhere('telefono', 'like', "%{$this->busqueda}%") + ->orWhere('canal_id', 'like', "%{$this->busqueda}%"); + }); + }) + ->orderByDesc('ultimo_mensaje_at') + ->get(); + + $convSelec = $this->convSelecId + ? ChatConversation::with('contact')->find($this->convSelecId) + : null; + + return view('livewire.chat.show-conversaciones', compact('conversaciones', 'convSelec')); + } +} diff --git a/app/Http/Livewire/Chat/ShowMenusChat.php b/app/Http/Livewire/Chat/ShowMenusChat.php new file mode 100644 index 0000000..380a559 --- /dev/null +++ b/app/Http/Livewire/Chat/ShowMenusChat.php @@ -0,0 +1,168 @@ +validate([ + 'menuNombre' => 'required|string|max:80', + 'menuMensaje' => 'required|string|max:500', + ]); + + if ($this->menuEsRaiz) { + ChatMenu::where('es_raiz', true)->update(['es_raiz' => false]); + } + + $data = [ + 'nombre' => $this->menuNombre, + 'mensaje' => $this->menuMensaje, + 'es_raiz' => $this->menuEsRaiz, + 'activo' => $this->menuActivo, + ]; + + if ($this->editMenuId) { + ChatMenu::where('id', $this->editMenuId)->update($data); + } else { + ChatMenu::create($data); + } + + $this->closeMenuForm(); + } + + public function editMenu(int $id): void + { + $menu = ChatMenu::findOrFail($id); + $this->editMenuId = $id; + $this->menuNombre = $menu->nombre; + $this->menuMensaje = $menu->mensaje; + $this->menuEsRaiz = (bool) $menu->es_raiz; + $this->menuActivo = (bool) $menu->activo; + $this->showMenuForm = true; + } + + public function deleteMenu(int $id): void + { + ChatMenu::destroy($id); + } + + public function closeMenuForm(): void + { + $this->showMenuForm = false; + $this->editMenuId = null; + $this->menuNombre = ''; + $this->menuMensaje = ''; + $this->menuEsRaiz = false; + $this->menuActivo = true; + } + + // ── CRUD OpciΓ³n ─────────────────────────────────────── + + public function saveOpcion(): void + { + $this->validate([ + 'opcionClave' => 'required|string|max:20', + 'opcionEtiqueta' => 'required|string|max:150', + 'opcionAccion' => 'required|in:ir_menu,respuesta,agente', + 'opcionMenuDestId' => 'nullable|exists:chat_menus,id', + 'opcionRespuesta' => 'nullable|string|max:1000', + ]); + + $data = [ + 'menu_id' => $this->opcionMenuId, + 'clave' => strtolower(trim($this->opcionClave)), + 'etiqueta' => $this->opcionEtiqueta, + 'accion' => $this->opcionAccion, + 'menu_destino_id' => $this->opcionAccion === 'ir_menu' ? $this->opcionMenuDestId : null, + 'respuesta_texto' => $this->opcionAccion === 'respuesta' ? $this->opcionRespuesta : null, + 'orden' => $this->opcionOrden, + ]; + + if ($this->editOpcionId) { + ChatMenuOption::where('id', $this->editOpcionId)->update($data); + } else { + ChatMenuOption::create($data); + } + + $this->closeOpcionForm(); + } + + public function openOpcionForm(int $menuId): void + { + $this->opcionMenuId = $menuId; + $this->editOpcionId = null; + $this->opcionClave = ''; + $this->opcionEtiqueta = ''; + $this->opcionAccion = 'respuesta'; + $this->opcionMenuDestId = null; + $this->opcionRespuesta = ''; + $this->opcionOrden = 0; + $this->showOpcionForm = true; + } + + public function editOpcion(int $id): void + { + $op = ChatMenuOption::findOrFail($id); + $this->editOpcionId = $id; + $this->opcionMenuId = $op->menu_id; + $this->opcionClave = $op->clave; + $this->opcionEtiqueta = $op->etiqueta; + $this->opcionAccion = $op->accion; + $this->opcionMenuDestId = $op->menu_destino_id; + $this->opcionRespuesta = $op->respuesta_texto ?? ''; + $this->opcionOrden = $op->orden; + $this->showOpcionForm = true; + } + + public function deleteOpcion(int $id): void + { + ChatMenuOption::destroy($id); + } + + public function closeOpcionForm(): void + { + $this->showOpcionForm = false; + $this->editOpcionId = null; + $this->opcionMenuId = null; + $this->opcionClave = ''; + $this->opcionEtiqueta = ''; + $this->opcionAccion = 'respuesta'; + $this->opcionMenuDestId = null; + $this->opcionRespuesta = ''; + $this->opcionOrden = 0; + } + + public function render() + { + $menus = ChatMenu::with('options.menuDestino')->orderByDesc('es_raiz')->orderBy('nombre')->get(); + $todosMenus = ChatMenu::orderBy('nombre')->get(); + + return view('livewire.chat.show-menus-chat', compact('menus', 'todosMenus')); + } +} diff --git a/app/Models/ChatConfig.php b/app/Models/ChatConfig.php new file mode 100644 index 0000000..c244705 --- /dev/null +++ b/app/Models/ChatConfig.php @@ -0,0 +1,24 @@ +value('config_value') ?? $default; + } + + public static function set(string $key, string $value): void + { + static::updateOrCreate( + ['config_key' => $key], + ['config_value' => $value] + ); + } +} diff --git a/app/Models/ChatContact.php b/app/Models/ChatContact.php new file mode 100644 index 0000000..13b091f --- /dev/null +++ b/app/Models/ChatContact.php @@ -0,0 +1,26 @@ + 'array']; + + public function conversations(): HasMany + { + return $this->hasMany(ChatConversation::class, 'contact_id'); + } + + public function activeConversation(): ?ChatConversation + { + return $this->conversations() + ->whereIn('estado', ['bot', 'agente']) + ->latest() + ->first(); + } +} diff --git a/app/Models/ChatConversation.php b/app/Models/ChatConversation.php new file mode 100644 index 0000000..04b4020 --- /dev/null +++ b/app/Models/ChatConversation.php @@ -0,0 +1,45 @@ + 'datetime']; + + public function contact(): BelongsTo + { + return $this->belongsTo(ChatContact::class, 'contact_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(ChatMessage::class, 'conversation_id'); + } + + public function menuActual(): BelongsTo + { + return $this->belongsTo(ChatMenu::class, 'menu_actual_id'); + } + + public function canalBadge(): string + { + return match($this->canal) { + 'telegram' => '✈️ Telegram', + default => 'πŸ’¬ Web', + }; + } + + public function canalColor(): string + { + return match($this->canal) { + 'telegram' => 'bg-blue-100 text-blue-700', + default => 'bg-green-100 text-green-700', + }; + } +} diff --git a/app/Models/ChatMenu.php b/app/Models/ChatMenu.php new file mode 100644 index 0000000..ab6b1ed --- /dev/null +++ b/app/Models/ChatMenu.php @@ -0,0 +1,23 @@ + 'boolean', 'activo' => 'boolean']; + + public function options(): HasMany + { + return $this->hasMany(ChatMenuOption::class, 'menu_id')->orderBy('orden'); + } + + public static function raiz(): ?self + { + return static::where('es_raiz', true)->where('activo', true)->first(); + } +} diff --git a/app/Models/ChatMenuOption.php b/app/Models/ChatMenuOption.php new file mode 100644 index 0000000..6889909 --- /dev/null +++ b/app/Models/ChatMenuOption.php @@ -0,0 +1,22 @@ +belongsTo(ChatMenu::class, 'menu_id'); + } + + public function menuDestino(): BelongsTo + { + return $this->belongsTo(ChatMenu::class, 'menu_destino_id'); + } +} diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php new file mode 100644 index 0000000..496c649 --- /dev/null +++ b/app/Models/ChatMessage.php @@ -0,0 +1,17 @@ +belongsTo(ChatConversation::class, 'conversation_id'); + } +} diff --git a/app/Services/ChatBotEngine.php b/app/Services/ChatBotEngine.php new file mode 100644 index 0000000..f619786 --- /dev/null +++ b/app/Services/ChatBotEngine.php @@ -0,0 +1,217 @@ +handle('web', $telefono, $textoUsuario); + * // $reply es un array de strings a enviar + */ +class ChatBotEngine +{ + // ───────────────────────────────────────────── + // Punto de entrada principal + // ───────────────────────────────────────────── + + /** + * @param string $canal 'web' | 'telegram' + * @param string $canalId telΓ©fono o chat_id de Telegram + * @param string $texto texto enviado por el usuario + * @param string $nombre nombre del contacto (opcional) + * @return string[] array de mensajes a enviar de vuelta + */ + public function handle(string $canal, string $canalId, string $texto, string $nombre = ''): array + { + $texto = trim($texto); + + // 1. Obtener o crear contacto + $contact = ChatContact::firstOrCreate( + ['canal' => $canal, 'canal_id' => $canalId], + ['nombre' => $nombre ?: $canalId, 'telefono' => $canal === 'web' ? $canalId : null] + ); + + if ($nombre && $contact->nombre !== $nombre) { + $contact->update(['nombre' => $nombre]); + } + + // 2. Obtener conversaciΓ³n activa o crear una nueva + $conv = $contact->activeConversation(); + + if (! $conv) { + $conv = ChatConversation::create([ + 'contact_id' => $contact->id, + 'canal' => $canal, + 'estado' => 'bot', + 'ultimo_mensaje_at' => now(), + ]); + } + + // 3. Guardar el mensaje del usuario + ChatMessage::create([ + 'conversation_id' => $conv->id, + 'tipo' => 'usuario', + 'contenido' => $texto, + 'leido' => false, + ]); + + $conv->update([ + 'ultimo_mensaje_at' => now(), + 'mensajes_no_leidos' => $conv->mensajes_no_leidos + 1, + ]); + + // 4. Si la conversaciΓ³n estΓ‘ en modo agente, no responder automΓ‘ticamente + if ($conv->estado === 'agente') { + return []; + } + + // 5. Procesar con el Γ‘rbol de menΓΊs + $replies = $this->processMenu($conv, $texto); + + // 6. Guardar respuestas del bot + foreach ($replies as $reply) { + ChatMessage::create([ + 'conversation_id' => $conv->id, + 'tipo' => 'bot', + 'contenido' => $reply, + 'leido' => true, + ]); + } + + return $replies; + } + + // ───────────────────────────────────────────── + // LΓ³gica de menΓΊs + // ───────────────────────────────────────────── + + private function processMenu(ChatConversation $conv, string $texto): array + { + // Cargar menΓΊ actual (si existe) o el menΓΊ raΓ­z + $menu = $conv->menu_actual_id + ? ChatMenu::find($conv->menu_actual_id) + : ChatMenu::raiz(); + + if (! $menu) { + return [$this->mensajeSinMenu()]; + } + + // Buscar opciΓ³n que coincida con lo que escribiΓ³ el usuario + $opcion = $menu->options() + ->where('clave', strtolower($texto)) + ->first(); + + if (! $opcion) { + // OpciΓ³n no reconocida β€” reenviar el menΓΊ actual + return [$menu->mensaje . "\n\n" . $this->formatOpciones($menu)]; + } + + return match($opcion->accion) { + 'ir_menu' => $this->irAMenu($conv, $opcion->menu_destino_id), + 'respuesta' => $this->respuestaSimple($conv, $opcion), + 'agente' => $this->transferirAgente($conv), + default => ['Lo siento, no pude procesar tu solicitud.'], + }; + } + + private function irAMenu(ChatConversation $conv, ?int $menuId): array + { + if (! $menuId) { + return ['Error: menΓΊ de destino no configurado.']; + } + + $menu = ChatMenu::find($menuId); + if (! $menu || ! $menu->activo) { + return ['Lo siento, esa opciΓ³n no estΓ‘ disponible en este momento.']; + } + + $conv->update(['menu_actual_id' => $menu->id]); + + return [$menu->mensaje . "\n\n" . $this->formatOpciones($menu)]; + } + + private function respuestaSimple(ChatConversation $conv, $opcion): array + { + // DespuΓ©s de la respuesta, volver al menΓΊ raΓ­z + $raiz = ChatMenu::raiz(); + $conv->update(['menu_actual_id' => $raiz?->id]); + + $replies = [$opcion->respuesta_texto]; + + if ($raiz) { + $replies[] = $raiz->mensaje . "\n\n" . $this->formatOpciones($raiz); + } + + return $replies; + } + + private function transferirAgente(ChatConversation $conv): array + { + $conv->update(['estado' => 'agente', 'menu_actual_id' => null]); + + return [ + ChatConfig::get('mensaje_transferencia', + 'Un agente se comunicarΓ‘ contigo en breve. Por favor espera.' + ) + ]; + } + + // ───────────────────────────────────────────── + // Helpers de formato + // ───────────────────────────────────────────── + + private function formatOpciones(\App\Models\ChatMenu $menu): string + { + $lines = []; + foreach ($menu->options as $op) { + $lines[] = "{$op->clave}. {$op->etiqueta}"; + } + return implode("\n", $lines); + } + + private function mensajeSinMenu(): string + { + return ChatConfig::get( + 'mensaje_bienvenida', + 'Hola πŸ‘‹ Bienvenido. Escribe *hola* para comenzar.' + ); + } + + // ───────────────────────────────────────────── + // EnvΓ­o hacia Telegram + // ───────────────────────────────────────────── + + public function enviarTelegram(string $chatId, string $texto): bool + { + $token = ChatConfig::get('telegram_token'); + if (! $token) { + return false; + } + + $url = "https://api.telegram.org/bot{$token}/sendMessage"; + $payload = json_encode(['chat_id' => $chatId, 'text' => $texto, 'parse_mode' => 'Markdown']); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $payload, + 'timeout' => 10, + ], + ]); + + $result = @file_get_contents($url, false, $ctx); + return $result !== false; + } +} diff --git a/database/migrations/2026_04_30_000001_create_chat_tables.php b/database/migrations/2026_04_30_000001_create_chat_tables.php new file mode 100644 index 0000000..d1f1a76 --- /dev/null +++ b/database/migrations/2026_04_30_000001_create_chat_tables.php @@ -0,0 +1,87 @@ +id(); + $table->string('config_key')->unique(); + $table->text('config_value')->nullable(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + + // ── Contactos (un registro por telΓ©fono/chat_id + canal) ───────── + Schema::create('chat_contacts', function (Blueprint $table) { + $table->id(); + $table->string('canal', 20); // 'web' | 'telegram' + $table->string('canal_id'); // telΓ©fono (web) o chat_id numΓ©rico (telegram) + $table->string('nombre')->nullable(); + $table->string('telefono')->nullable(); + $table->json('metadata')->nullable(); // datos extra del canal + $table->timestamps(); + $table->unique(['canal', 'canal_id']); + }); + + // ── Conversaciones ──────────────────────────────────────────────── + Schema::create('chat_conversations', function (Blueprint $table) { + $table->id(); + $table->foreignId('contact_id')->constrained('chat_contacts')->cascadeOnDelete(); + $table->string('canal', 20); // 'web' | 'telegram' + $table->string('estado', 20)->default('bot'); // 'bot' | 'agente' | 'cerrada' + $table->integer('menu_actual_id')->nullable(); // id del menΓΊ donde estΓ‘ el usuario + $table->timestamp('ultimo_mensaje_at')->nullable(); + $table->unsignedInteger('mensajes_no_leidos')->default(0); + $table->timestamps(); + }); + + // ── Mensajes ────────────────────────────────────────────────────── + Schema::create('chat_messages', function (Blueprint $table) { + $table->id(); + $table->foreignId('conversation_id')->constrained('chat_conversations')->cascadeOnDelete(); + $table->string('tipo', 20); // 'usuario' | 'bot' | 'agente' + $table->text('contenido'); + $table->boolean('leido')->default(false); + $table->timestamps(); + }); + + // ── MenΓΊs del bot ───────────────────────────────────────────────── + Schema::create('chat_menus', function (Blueprint $table) { + $table->id(); + $table->string('nombre'); + $table->text('mensaje'); // texto que envΓ­a el bot al llegar a este menΓΊ + $table->boolean('es_raiz')->default(false); + $table->boolean('activo')->default(true); + $table->timestamps(); + }); + + // ── Opciones de menΓΊ ────────────────────────────────────────────── + Schema::create('chat_menu_options', function (Blueprint $table) { + $table->id(); + $table->foreignId('menu_id')->constrained('chat_menus')->cascadeOnDelete(); + $table->string('clave', 20); // lo que el usuario escribe: '1', '2', 'a', etc. + $table->string('etiqueta'); // texto descriptivo de la opciΓ³n + $table->string('accion', 30); // 'ir_menu' | 'respuesta' | 'agente' + $table->unsignedBigInteger('menu_destino_id')->nullable(); // si accion = ir_menu + $table->text('respuesta_texto')->nullable(); // si accion = respuesta + $table->integer('orden')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('chat_menu_options'); + Schema::dropIfExists('chat_menus'); + Schema::dropIfExists('chat_messages'); + Schema::dropIfExists('chat_conversations'); + Schema::dropIfExists('chat_contacts'); + Schema::dropIfExists('chat_config'); + } +}; diff --git a/resources/views/chat-publico.blade.php b/resources/views/chat-publico.blade.php new file mode 100644 index 0000000..c4d8caf --- /dev/null +++ b/resources/views/chat-publico.blade.php @@ -0,0 +1,19 @@ + + + + + + + Chat en vivo | {{ config('app.name') }} + + + @livewireStyles + + + + + + + @livewireScripts + + diff --git a/resources/views/chat/configuracion.blade.php b/resources/views/chat/configuracion.blade.php new file mode 100644 index 0000000..4f0474a --- /dev/null +++ b/resources/views/chat/configuracion.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/resources/views/chat/conversaciones.blade.php b/resources/views/chat/conversaciones.blade.php new file mode 100644 index 0000000..965bde1 --- /dev/null +++ b/resources/views/chat/conversaciones.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/chat/menus.blade.php b/resources/views/chat/menus.blade.php new file mode 100644 index 0000000..69f527c --- /dev/null +++ b/resources/views/chat/menus.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 507524a..c897823 100755 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -291,6 +291,38 @@ @endif + {{-- MΓ³dulo Chat (Web + Telegram) --}} + @if (in_array(auth()->user()->rol->nombre, ['super', 'administrador'])) +
+ + +
+ @endif + diff --git a/resources/views/livewire/chat/public-chat.blade.php b/resources/views/livewire/chat/public-chat.blade.php new file mode 100644 index 0000000..0bb150f --- /dev/null +++ b/resources/views/livewire/chat/public-chat.blade.php @@ -0,0 +1,120 @@ +
+ + {{-- ══ PASO 1: Formulario de inicio ══ --}} + @if ($paso === 'telefono') +
+
+ +
+
+ + + +
+

Chat en vivo

+

Ingresa tu nΓΊmero para comenzar

+
+ +
+
+ + + @error('telefono') + {{ $message }} + @enderror +
+
+ + +
+ +
+ +
+
+ + {{-- ══ PASO 2: Interfaz de chat ══ --}} + @else +
+ + {{-- Header --}} +
+
+ + + +
+
+

Soporte en lΓ­nea

+

+ @if ($estadoConv === 'agente') Atendido por un agente + @elseif ($estadoConv === 'cerrada') ConversaciΓ³n cerrada + @else Bot activo + @endif +

+
+
+ + {{-- Mensajes --}} +
+ @forelse($mensajes as $msg) + @if ($msg['tipo'] === 'usuario') +
+
+

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+
+ @else +
+
+ @if ($msg['tipo'] === 'agente') + Agente + @endif +

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+
+ @endif + @empty +
Iniciando chat...
+ @endforelse +
+ + {{-- Input --}} + @if ($estadoConv !== 'cerrada') +
+ + +
+ @else +
+ Esta conversaciΓ³n ha sido cerrada. +
+ @endif + +
+ @endif + +
diff --git a/resources/views/livewire/chat/show-configuracion-chat.blade.php b/resources/views/livewire/chat/show-configuracion-chat.blade.php new file mode 100644 index 0000000..fe66bcf --- /dev/null +++ b/resources/views/livewire/chat/show-configuracion-chat.blade.php @@ -0,0 +1,82 @@ +
+ +
+

ConfiguraciΓ³n del Chat

+

ParΓ‘metros generales del chatbot y del canal Telegram.

+
+ + {{-- Formulario --}} +
+ + {{-- Token Telegram --}} +
+ +

Obtenlo hablando con @BotFather en Telegram. Deja vacΓ­o si no usas Telegram.

+ +
+ + {{-- Webhook URL (solo lectura) --}} +
+ +

Esta es la URL que Telegram usarΓ‘ para enviarte los mensajes entrantes.

+
+ + +
+ @if($webhookResult) +

+ {{ $webhookResult }} +

+ @endif +
+ +
+ + {{-- Mensaje de bienvenida --}} +
+ +

Primer mensaje cuando el bot no tiene menΓΊ configurado o cuando el usuario escribe "hola".

+ + @error('mensaje_bienvenida') {{ $message }} @enderror +
+ + {{-- Mensaje de transferencia --}} +
+ +

Se envΓ­a al usuario cuando elige la opciΓ³n de hablar con un agente.

+ + @error('mensaje_transferencia') {{ $message }} @enderror +
+ + {{-- Guardar --}} +
+ +
+
+ + {{-- Info de canales --}} +
+

ℹ️ Canales disponibles

+
    +
  • Web: Los visitantes acceden en {{ url('/chat') }}
  • +
  • Telegram: Configurar el token y registrar el webhook para recibir mensajes de Telegram.
  • +
+
+ +
diff --git a/resources/views/livewire/chat/show-conversaciones.blade.php b/resources/views/livewire/chat/show-conversaciones.blade.php new file mode 100644 index 0000000..b7a2989 --- /dev/null +++ b/resources/views/livewire/chat/show-conversaciones.blade.php @@ -0,0 +1,201 @@ +
+ + {{-- ══ Panel izquierdo: lista de conversaciones ══ --}} +
+ + {{-- Header --}} +
+ + + + Conversaciones +
+ + {{-- BΓΊsqueda + filtros --}} +
+ +
+ + + + +
+
+ + {{-- Lista conversaciones --}} +
+ @forelse($conversaciones as $conv) + + @empty +
Sin conversaciones
+ @endforelse +
+
+ + {{-- ══ Panel derecho: mensajes ══ --}} + @if($convSelec) +
+ + {{-- Header conv seleccionada --}} +
+
+ + {{ strtoupper(substr($convSelec->contact?->nombre ?? '?', 0, 1)) }} + +
+
+

{{ $convSelec->contact?->nombre ?? 'Sin nombre' }}

+

+ {{ $convSelec->canal === 'telegram' ? 'Telegram' : 'Web' }} Β· + {{ $convSelec->contact?->telefono ?? $convSelec->contact?->canal_id }} +

+
+ {{-- Acciones --}} +
+ @if($convSelec->estado === 'bot') + + @elseif($convSelec->estado === 'agente') + + @endif + @if($convSelec->estado !== 'cerrada') + + @endif +
+
+ + {{-- Mensajes --}} +
+ @forelse($mensajes as $msg) + @if ($msg['tipo'] === 'usuario') +
+
+

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+
+ @elseif ($msg['tipo'] === 'bot') +
+
+ Bot +

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+
+ @else +
+
+ Agente +

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+
+ @endif + @empty +
Sin mensajes
+ @endforelse +
+ + {{-- Input respuesta (solo en estado agente) --}} + @if($convSelec->estado === 'agente') +
+ + +
+ @elseif($convSelec->estado === 'bot') +
+ El bot estΓ‘ respondiendo. Usa Tomar control para responder manualmente. +
+ @else +
+ ConversaciΓ³n cerrada. +
+ @endif + +
+ @else +
+
+
+ + + +
+

Selecciona una conversaciΓ³n para ver los mensajes

+
+
+ @endif + +
diff --git a/resources/views/livewire/chat/show-menus-chat.blade.php b/resources/views/livewire/chat/show-menus-chat.blade.php new file mode 100644 index 0000000..c68af95 --- /dev/null +++ b/resources/views/livewire/chat/show-menus-chat.blade.php @@ -0,0 +1,231 @@ +
+ + {{-- Cabecera --}} +
+
+

MenΓΊs del Bot

+

Configura el Γ‘rbol de menΓΊs que usa el chatbot.

+
+ +
+ + {{-- Modal MenΓΊ --}} + @if($showMenuForm) +
+
+
+

{{ $editMenuId ? 'Editar MenΓΊ' : 'Nuevo MenΓΊ' }}

+ +
+
+
+ + + @error('menuNombre') {{ $message }} @enderror +
+
+ + + @error('menuMensaje') {{ $message }} @enderror +
+
+ + +
+
+
+ + +
+
+
+ @endif + + {{-- Modal OpciΓ³n --}} + @if($showOpcionForm) +
+
+
+

{{ $editOpcionId ? 'Editar OpciΓ³n' : 'Nueva OpciΓ³n' }}

+ +
+
+
+
+ + + @error('opcionClave') {{ $message }} @enderror +
+
+ + +
+
+
+ + + @error('opcionEtiqueta') {{ $message }} @enderror +
+
+ + +
+ + @if($opcionAccion === 'ir_menu') +
+ + + @error('opcionMenuDestId') {{ $message }} @enderror +
+ @endif + + @if($opcionAccion === 'respuesta') +
+ + + @error('opcionRespuesta') {{ $message }} @enderror +
+ @endif + + @if($opcionAccion === 'agente') +

+ ⚠️ Al elegir esta opción, la conversación se transferirÑ a un agente humano. +

+ @endif +
+
+ + +
+
+
+ @endif + + {{-- Lista de menΓΊs --}} + @forelse($menus as $menu) +
+ {{-- Header del menΓΊ --}} +
+
+
+ @if($menu->es_raiz) + RAÍZ + @endif + @if(!$menu->activo) + INACTIVO + @endif +
+
+

{{ $menu->nombre }}

+

{{ $menu->mensaje }}

+
+
+
+ + + +
+
+ + {{-- Opciones del menΓΊ --}} + @if($menu->options->count() > 0) +
+ @foreach($menu->options as $op) +
+
+ + {{ $op->clave }} + +
+

{{ $op->etiqueta }}

+
+ @if($op->accion === 'ir_menu') + β†’ MenΓΊ + @if($op->menuDestino) + {{ $op->menuDestino->nombre }} + @endif + @elseif($op->accion === 'respuesta') + Respuesta + {{ $op->respuesta_texto }} + @else + β†’ Agente + @endif +
+
+
+
+ + +
+
+ @endforeach +
+ @else +
+ Sin opciones. +
+ @endif +
+ @empty +
+ + + +

No hay menΓΊs configurados todavΓ­a.

+ +
+ @endforelse + +
diff --git a/routes/web.php b/routes/web.php index 56304ea..ed37c39 100755 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,8 @@ group(function () { Route::get('/logs', [WhatsappController::class, 'logs'])->name('logs'); Route::get('/correo', [WhatsappController::class, 'correo'])->name('correo'); }); + + // MΓ³dulo Chat (web + Telegram) + Route::prefix('chat')->name('chat.')->group(function () { + Route::get('/conversaciones', [ChatController::class, 'conversaciones'])->name('conversaciones'); + Route::get('/menus', [ChatController::class, 'menus'])->name('menus'); + Route::get('/configuracion', [ChatController::class, 'configuracion'])->name('configuracion'); + }); }); Route::post('/registrar-accion-compra', [MenuController::class, 'click_compra'])->name('click_compra'); +// Rutas pΓΊblicas del mΓ³dulo Chat (sin autenticaciΓ³n) +Route::get('/chat', [ChatController::class, 'publicPage'])->name('chat.publico'); +Route::post('/chat/webhook/telegram', [TelegramWebhookController::class, 'handle']) + ->name('chat.telegram.webhook') + ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); + // Route::get('/ip-test', [MenuController::class, 'ip_test'])->name('ip');