chatbot nuevo

This commit is contained in:
Lizandro Guarnizo
2026-04-30 21:32:32 -05:00
parent 88a42ec629
commit f61e8005fc
24 changed files with 1724 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
class ChatController extends Controller
{
public function publicPage()
{
return view('chat-publico');
}
public function conversaciones()
{
return view('chat.conversaciones');
}
public function menus()
{
return view('chat.menus');
}
public function configuracion()
{
return view('chat.configuracion');
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Services\ChatBotEngine;
use Illuminate\Http\Request;
class TelegramWebhookController extends Controller
{
public function handle(Request $request)
{
$update = $request->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]);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Livewire\Chat;
use App\Models\ChatContact;
use App\Models\ChatConversation;
use App\Models\ChatMessage;
use App\Services\ChatBotEngine;
use Livewire\Component;
class PublicChat extends Component
{
public string $paso = 'telefono'; // 'telefono' | 'chat'
public string $telefono = '';
public string $nombre = '';
public string $input = '';
public array $mensajes = [];
public ?int $convId = null;
public string $estadoConv = 'bot';
public function iniciar()
{
$this->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');
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Livewire\Chat;
use App\Models\ChatConfig;
use Jantinnerezo\LivewireAlert\LivewireAlert;
use Livewire\Component;
class ShowConfiguracionChat extends Component
{
use LivewireAlert;
public string $telegram_token = '';
public string $mensaje_bienvenida = '';
public string $mensaje_transferencia = '';
public string $webhookResult = '';
public function mount(): void
{
$this->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');
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Livewire\Chat;
use App\Models\ChatConversation;
use App\Models\ChatMessage;
use App\Services\ChatBotEngine;
use Livewire\Component;
class ShowConversaciones extends Component
{
public string $busqueda = '';
public string $filtroEstado = '';
public ?int $convSelecId = null;
public string $replyText = '';
public array $mensajes = [];
public function selectConv(int $convId): void
{
$this->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'));
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace App\Http\Livewire\Chat;
use App\Models\ChatMenu;
use App\Models\ChatMenuOption;
use Livewire\Component;
class ShowMenusChat extends Component
{
// ── Formulario Menú ──────────────────────────────────
public bool $showMenuForm = false;
public ?int $editMenuId = null;
public string $menuNombre = '';
public string $menuMensaje = '';
public bool $menuEsRaiz = false;
public bool $menuActivo = true;
// ── Formulario Opción ─────────────────────────────────
public bool $showOpcionForm = false;
public ?int $editOpcionId = null;
public ?int $opcionMenuId = null;
public string $opcionClave = '';
public string $opcionEtiqueta = '';
public string $opcionAccion = 'respuesta'; // ir_menu | respuesta | agente
public ?int $opcionMenuDestId = null;
public string $opcionRespuesta = '';
public int $opcionOrden = 0;
// ── CRUD Menú ─────────────────────────────────────────
public function saveMenu(): void
{
$this->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'));
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ChatConfig extends Model
{
protected $table = 'chat_config';
protected $fillable = ['config_key', 'config_value', 'description'];
public static function get(string $key, string $default = ''): string
{
return static::where('config_key', $key)->value('config_value') ?? $default;
}
public static function set(string $key, string $value): void
{
static::updateOrCreate(
['config_key' => $key],
['config_value' => $value]
);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ChatContact extends Model
{
protected $table = 'chat_contacts';
protected $fillable = ['canal', 'canal_id', 'nombre', 'telefono', 'metadata'];
protected $casts = ['metadata' => '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();
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ChatConversation extends Model
{
protected $table = 'chat_conversations';
protected $fillable = ['contact_id', 'canal', 'estado', 'menu_actual_id', 'ultimo_mensaje_at', 'mensajes_no_leidos'];
protected $casts = ['ultimo_mensaje_at' => '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',
};
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ChatMenu extends Model
{
protected $table = 'chat_menus';
protected $fillable = ['nombre', 'mensaje', 'es_raiz', 'activo'];
protected $casts = ['es_raiz' => '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();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatMenuOption extends Model
{
protected $table = 'chat_menu_options';
protected $fillable = ['menu_id', 'clave', 'etiqueta', 'accion', 'menu_destino_id', 'respuesta_texto', 'orden'];
public function menu(): BelongsTo
{
return $this->belongsTo(ChatMenu::class, 'menu_id');
}
public function menuDestino(): BelongsTo
{
return $this->belongsTo(ChatMenu::class, 'menu_destino_id');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatMessage extends Model
{
protected $table = 'chat_messages';
protected $fillable = ['conversation_id', 'tipo', 'contenido', 'leido'];
public function conversation(): BelongsTo
{
return $this->belongsTo(ChatConversation::class, 'conversation_id');
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Services;
use App\Models\ChatConfig;
use App\Models\ChatContact;
use App\Models\ChatConversation;
use App\Models\ChatMessage;
use App\Models\ChatMenu;
/**
* ChatBotEngine
*
* Procesa un mensaje entrante de cualquier canal y devuelve
* la respuesta que el bot debe enviar.
*
* Uso:
* $engine = new ChatBotEngine();
* $reply = $engine->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;
}
}