chatbot nuevo
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user