From 148fa6b9f9175212aeb12828d144069070000e60 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:40:10 -0500 Subject: [PATCH] up --- app/Http/Controllers/WhatsappController.php | 38 + .../Whatsapp/ShowConfiguracionBot.php | 71 ++ .../Livewire/Whatsapp/ShowConversaciones.php | 112 +++ .../Livewire/Whatsapp/ShowLogsWebhook.php | 50 + app/Http/Livewire/Whatsapp/ShowMenusBot.php | 194 ++++ app/Http/Livewire/Whatsapp/ShowPlantillas.php | 113 +++ .../Livewire/Whatsapp/ShowProgramados.php | 100 ++ app/Models/WhatsappConversation.php | 28 + app/Models/WhatsappMenu.php | 34 + app/Models/WhatsappMenuOption.php | 25 + app/Models/WhatsappMessageTemplate.php | 20 + app/Models/WhatsappScheduledMessage.php | 28 + app/Models/WhatsappSystemConfig.php | 26 + app/Models/WhatsappUser.php | 44 + app/Models/WhatsappWebhookLog.php | 17 + ..._24_000001_create_whatsapp_users_table.php | 37 + ...02_create_whatsapp_conversations_table.php | 41 + ..._24_000003_create_whatsapp_menus_table.php | 42 + ...reate_whatsapp_message_templates_table.php | 31 + ...05_create_whatsapp_system_config_table.php | 39 + ...eate_whatsapp_scheduled_and_logs_table.php | 44 + public/DOCUMENTACION_BOT_WHATSAPP.md | 916 ++++++++++++++++++ resources/views/layouts/navigation.blade.php | 44 + .../whatsapp/show-configuracion-bot.blade.php | 118 +++ .../whatsapp/show-conversaciones.blade.php | 166 ++++ .../whatsapp/show-logs-webhook.blade.php | 82 ++ .../whatsapp/show-menus-bot.blade.php | 250 +++++ .../whatsapp/show-plantillas.blade.php | 141 +++ .../whatsapp/show-programados.blade.php | 142 +++ .../views/whatsapp/configuracion.blade.php | 6 + .../views/whatsapp/conversaciones.blade.php | 3 + resources/views/whatsapp/logs.blade.php | 6 + resources/views/whatsapp/menus.blade.php | 6 + resources/views/whatsapp/plantillas.blade.php | 6 + .../views/whatsapp/programados.blade.php | 6 + routes/web.php | 11 + 36 files changed, 3037 insertions(+) create mode 100644 app/Http/Controllers/WhatsappController.php create mode 100644 app/Http/Livewire/Whatsapp/ShowConfiguracionBot.php create mode 100644 app/Http/Livewire/Whatsapp/ShowConversaciones.php create mode 100644 app/Http/Livewire/Whatsapp/ShowLogsWebhook.php create mode 100644 app/Http/Livewire/Whatsapp/ShowMenusBot.php create mode 100644 app/Http/Livewire/Whatsapp/ShowPlantillas.php create mode 100644 app/Http/Livewire/Whatsapp/ShowProgramados.php create mode 100644 app/Models/WhatsappConversation.php create mode 100644 app/Models/WhatsappMenu.php create mode 100644 app/Models/WhatsappMenuOption.php create mode 100644 app/Models/WhatsappMessageTemplate.php create mode 100644 app/Models/WhatsappScheduledMessage.php create mode 100644 app/Models/WhatsappSystemConfig.php create mode 100644 app/Models/WhatsappUser.php create mode 100644 app/Models/WhatsappWebhookLog.php create mode 100644 database/migrations/2026_04_24_000001_create_whatsapp_users_table.php create mode 100644 database/migrations/2026_04_24_000002_create_whatsapp_conversations_table.php create mode 100644 database/migrations/2026_04_24_000003_create_whatsapp_menus_table.php create mode 100644 database/migrations/2026_04_24_000004_create_whatsapp_message_templates_table.php create mode 100644 database/migrations/2026_04_24_000005_create_whatsapp_system_config_table.php create mode 100644 database/migrations/2026_04_24_000006_create_whatsapp_scheduled_and_logs_table.php create mode 100644 public/DOCUMENTACION_BOT_WHATSAPP.md create mode 100644 resources/views/livewire/whatsapp/show-configuracion-bot.blade.php create mode 100644 resources/views/livewire/whatsapp/show-conversaciones.blade.php create mode 100644 resources/views/livewire/whatsapp/show-logs-webhook.blade.php create mode 100644 resources/views/livewire/whatsapp/show-menus-bot.blade.php create mode 100644 resources/views/livewire/whatsapp/show-plantillas.blade.php create mode 100644 resources/views/livewire/whatsapp/show-programados.blade.php create mode 100644 resources/views/whatsapp/configuracion.blade.php create mode 100644 resources/views/whatsapp/conversaciones.blade.php create mode 100644 resources/views/whatsapp/logs.blade.php create mode 100644 resources/views/whatsapp/menus.blade.php create mode 100644 resources/views/whatsapp/plantillas.blade.php create mode 100644 resources/views/whatsapp/programados.blade.php diff --git a/app/Http/Controllers/WhatsappController.php b/app/Http/Controllers/WhatsappController.php new file mode 100644 index 0000000..aaed28e --- /dev/null +++ b/app/Http/Controllers/WhatsappController.php @@ -0,0 +1,38 @@ + 'required|string', + 'phone_number_id' => 'required|string', + 'whatsapp_api_url' => 'required|url', + 'webhook_verify_token' => 'required|string', + 'welcome_message' => 'required|string|max:1000', + 'default_no_match' => 'required|string|max:500', + 'advisor_message' => 'required|string|max:500', + ]; + + public function mount(): void + { + $keys = [ + 'whatsapp_token', 'phone_number_id', 'whatsapp_api_url', + 'webhook_verify_token', 'bot_enabled', 'welcome_message', + 'default_no_match', 'advisor_message', 'business_hours_enabled', + 'business_hours_start', 'business_hours_end', + ]; + + foreach ($keys as $key) { + $this->{$key} = WhatsappSystemConfig::get($key); + } + } + + public function save(): void + { + $this->validate(); + + $keys = [ + 'whatsapp_token', 'phone_number_id', 'whatsapp_api_url', + 'webhook_verify_token', 'bot_enabled', 'welcome_message', + 'default_no_match', 'advisor_message', 'business_hours_enabled', + 'business_hours_start', 'business_hours_end', + ]; + + foreach ($keys as $key) { + WhatsappSystemConfig::set($key, $this->{$key}); + } + + $this->alert('success', 'Configuración guardada correctamente.'); + } + + public function render() + { + return view('livewire.whatsapp.show-configuracion-bot'); + } +} diff --git a/app/Http/Livewire/Whatsapp/ShowConversaciones.php b/app/Http/Livewire/Whatsapp/ShowConversaciones.php new file mode 100644 index 0000000..22d378f --- /dev/null +++ b/app/Http/Livewire/Whatsapp/ShowConversaciones.php @@ -0,0 +1,112 @@ + '$refresh']; + + public function selectUser(int $userId): void + { + $this->selectedUserId = $userId; + $this->selectedUser = WhatsappUser::find($userId); + $this->replyText = ''; + + // Marcar mensajes como leídos + WhatsappConversation::where('user_id', $userId) + ->where('direction', 'incoming') + ->where('is_read', 0) + ->update(['is_read' => 1]); + } + + public function sendReply(): void + { + $this->validate(['replyText' => 'required|string|max:4096']); + + if (! $this->selectedUser) { + return; + } + + $token = WhatsappSystemConfig::get('whatsapp_token'); + $phoneNumberId = WhatsappSystemConfig::get('phone_number_id'); + $apiUrl = WhatsappSystemConfig::get('whatsapp_api_url', 'https://graph.facebook.com/v22.0/'); + + $response = Http::withToken($token) + ->post("{$apiUrl}{$phoneNumberId}/messages", [ + 'messaging_product' => 'whatsapp', + 'to' => $this->selectedUser->phone_number, + 'type' => 'text', + 'text' => ['body' => $this->replyText], + ]); + + if ($response->successful()) { + WhatsappConversation::create([ + 'user_id' => $this->selectedUserId, + 'message_id' => $response->json('messages.0.id'), + 'direction' => 'outgoing', + 'message_type' => 'text', + 'content' => $this->replyText, + 'status' => 'sent', + ]); + + $this->replyText = ''; + $this->dispatch('scrollToBottom'); + } else { + session()->flash('error', 'Error al enviar el mensaje: ' . $response->body()); + } + } + + public function toggleBlockUser(): void + { + if (! $this->selectedUser) { + return; + } + + $newStatus = $this->selectedUser->status === 'blocked' ? 'active' : 'blocked'; + $this->selectedUser->update(['status' => $newStatus]); + $this->selectedUser->refresh(); + } + + public function render() + { + $users = WhatsappUser::when($this->search, function ($q) { + $q->where(function ($q2) { + $q2->where('name', 'like', '%' . $this->search . '%') + ->orWhere('phone_number', 'like', '%' . $this->search . '%'); + }); + }) + ->withCount(['conversations as unread_count' => function ($q) { + $q->where('direction', 'incoming')->where('is_read', 0); + }]) + ->with('lastMessage') + ->orderByDesc( + WhatsappConversation::select('created_at') + ->whereColumn('user_id', 'whatsapp_users.id') + ->orderByDesc('created_at') + ->limit(1) + ) + ->paginate(20); + + $messages = $this->selectedUserId + ? WhatsappConversation::where('user_id', $this->selectedUserId) + ->orderBy('created_at') + ->get() + : collect(); + + return view('livewire.whatsapp.show-conversaciones', compact('users', 'messages')); + } +} diff --git a/app/Http/Livewire/Whatsapp/ShowLogsWebhook.php b/app/Http/Livewire/Whatsapp/ShowLogsWebhook.php new file mode 100644 index 0000000..26b44cb --- /dev/null +++ b/app/Http/Livewire/Whatsapp/ShowLogsWebhook.php @@ -0,0 +1,50 @@ +viewId = $id; + $decoded = json_decode($log->payload, true); + $this->viewPayload = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + + public function clearView(): void + { + $this->viewId = null; + $this->viewPayload = null; + } + + public function deleteLog(int $id): void + { + WhatsappWebhookLog::findOrFail($id)->delete(); + } + + public function clearAll(): void + { + WhatsappWebhookLog::truncate(); + } + + public function render() + { + $logs = WhatsappWebhookLog::when($this->search, fn($q) => + $q->where('payload', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(25); + + return view('livewire.whatsapp.show-logs-webhook', compact('logs')); + } +} diff --git a/app/Http/Livewire/Whatsapp/ShowMenusBot.php b/app/Http/Livewire/Whatsapp/ShowMenusBot.php new file mode 100644 index 0000000..fa54c64 --- /dev/null +++ b/app/Http/Livewire/Whatsapp/ShowMenusBot.php @@ -0,0 +1,194 @@ + 'required|string|max:255', + 'menuTitle' => 'required|string|max:255', + 'menuMessage' => 'required|string', + ]; + } + + public function openMenuForm(?int $id = null): void + { + $this->resetMenuForm(); + if ($id) { + $menu = WhatsappMenu::findOrFail($id); + $this->editMenuId = $id; + $this->menuName = $menu->name; + $this->menuTitle = $menu->title; + $this->menuMessage = $menu->message; + $this->menuParentId = $menu->parent_id; + $this->menuIsMain = (bool) $menu->is_main; + $this->menuIsActive = (bool) $menu->is_active; + $this->menuSortOrder = $menu->sort_order; + } + $this->showMenuForm = true; + } + + public function saveMenu(): void + { + $this->validate(); + + $data = [ + 'name' => $this->menuName, + 'title' => $this->menuTitle, + 'message' => $this->menuMessage, + 'parent_id' => $this->menuParentId ?: null, + 'is_main' => $this->menuIsMain, + 'is_active' => $this->menuIsActive, + 'sort_order' => $this->menuSortOrder, + ]; + + if ($this->editMenuId) { + WhatsappMenu::findOrFail($this->editMenuId)->update($data); + $this->alert('success', 'Menú actualizado correctamente.'); + } else { + WhatsappMenu::create($data); + $this->alert('success', 'Menú creado correctamente.'); + } + + $this->resetMenuForm(); + } + + public function deleteMenu(int $id): void + { + WhatsappMenu::findOrFail($id)->delete(); + $this->alert('success', 'Menú eliminado.'); + if ($this->viewMenuId === $id) { + $this->viewMenuId = null; + } + } + + public function openOptionForm(?int $optionId = null, ?int $menuId = null): void + { + $this->resetOptionForm(); + $this->optionForMenuId = $menuId; + if ($optionId) { + $opt = WhatsappMenuOption::findOrFail($optionId); + $this->editOptionId = $optionId; + $this->optionForMenuId = $opt->menu_id; + $this->optionNumber = $opt->option_number; + $this->optionTitle = $opt->title; + $this->optionAction = $opt->action_type; + $this->optionValue = $opt->action_value ?? ''; + $this->optionSort = $opt->sort_order; + $this->optionIsActive = (bool) $opt->is_active; + } + $this->showOptionForm = true; + } + + public function saveOption(): void + { + $this->validate([ + 'optionTitle' => 'required|string|max:255', + 'optionForMenuId' => 'required|integer', + 'optionNumber' => 'required|integer|min:1', + ]); + + $data = [ + 'menu_id' => $this->optionForMenuId, + 'option_number' => $this->optionNumber, + 'title' => $this->optionTitle, + 'action_type' => $this->optionAction, + 'action_value' => $this->optionValue, + 'sort_order' => $this->optionSort, + 'is_active' => $this->optionIsActive, + ]; + + if ($this->editOptionId) { + WhatsappMenuOption::findOrFail($this->editOptionId)->update($data); + $this->alert('success', 'Opción actualizada.'); + } else { + WhatsappMenuOption::create($data); + $this->alert('success', 'Opción creada.'); + } + + $this->resetOptionForm(); + } + + public function deleteOption(int $id): void + { + WhatsappMenuOption::findOrFail($id)->delete(); + $this->alert('success', 'Opción eliminada.'); + } + + public function resetMenuForm(): void + { + $this->showMenuForm = false; + $this->editMenuId = null; + $this->menuName = ''; + $this->menuTitle = ''; + $this->menuMessage = ''; + $this->menuParentId = null; + $this->menuIsMain = false; + $this->menuIsActive = true; + $this->menuSortOrder = 0; + } + + public function resetOptionForm(): void + { + $this->showOptionForm = false; + $this->editOptionId = null; + $this->optionForMenuId = null; + $this->optionNumber = 1; + $this->optionTitle = ''; + $this->optionAction = 'message'; + $this->optionValue = ''; + $this->optionSort = 0; + $this->optionIsActive = true; + } + + public function render() + { + $menus = WhatsappMenu::when($this->search, fn($q) => $q->where('name', 'like', "%{$this->search}%") + ->orWhere('title', 'like', "%{$this->search}%")) + ->withCount('options') + ->orderBy('sort_order') + ->get(); + + $viewMenu = $this->viewMenuId ? WhatsappMenu::with('options')->find($this->viewMenuId) : null; + $allMenus = WhatsappMenu::orderBy('title')->get(); + + return view('livewire.whatsapp.show-menus-bot', compact('menus', 'viewMenu', 'allMenus')); + } +} diff --git a/app/Http/Livewire/Whatsapp/ShowPlantillas.php b/app/Http/Livewire/Whatsapp/ShowPlantillas.php new file mode 100644 index 0000000..32a688b --- /dev/null +++ b/app/Http/Livewire/Whatsapp/ShowPlantillas.php @@ -0,0 +1,113 @@ + 'required|string|max:255', + 'templateName' => 'required|string|max:255|regex:/^[a-z0-9_]+$/', + 'languageCode' => 'required|string|max:10', + 'bodyText' => 'required|string', + ]; + } + + public function openForm(?int $id = null): void + { + $this->resetForm(); + if ($id) { + $tpl = WhatsappMessageTemplate::findOrFail($id); + $this->editId = $id; + $this->name = $tpl->name; + $this->templateName = $tpl->template_name; + $this->languageCode = $tpl->language_code; + $this->status = $tpl->status; + $this->bodyText = $tpl->body_text ?? ''; + $this->headerType = $tpl->header_type; + $this->headerText = $tpl->header_text ?? ''; + $this->footerText = $tpl->footer_text ?? ''; + } + $this->showForm = true; + } + + public function save(): void + { + $this->validate(); + + $data = [ + 'name' => $this->name, + 'template_name' => $this->templateName, + 'language_code' => $this->languageCode, + 'status' => $this->status, + 'body_text' => $this->bodyText, + 'header_type' => $this->headerType, + 'header_text' => $this->headerText, + 'footer_text' => $this->footerText, + ]; + + if ($this->editId) { + WhatsappMessageTemplate::findOrFail($this->editId)->update($data); + $this->alert('success', 'Plantilla actualizada.'); + } else { + WhatsappMessageTemplate::create($data); + $this->alert('success', 'Plantilla creada.'); + } + + $this->resetForm(); + } + + public function delete(int $id): void + { + WhatsappMessageTemplate::findOrFail($id)->delete(); + $this->alert('success', 'Plantilla eliminada.'); + } + + public function resetForm(): void + { + $this->showForm = false; + $this->editId = null; + $this->name = ''; + $this->templateName = ''; + $this->languageCode = 'es'; + $this->status = 'pending'; + $this->bodyText = ''; + $this->headerType = 'none'; + $this->headerText = ''; + $this->footerText = ''; + } + + public function render() + { + $templates = WhatsappMessageTemplate::when($this->search, fn($q) => + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('template_name', 'like', "%{$this->search}%")) + ->when($this->filterStatus, fn($q) => $q->where('status', $this->filterStatus)) + ->orderByDesc('updated_at') + ->paginate(15); + + return view('livewire.whatsapp.show-plantillas', compact('templates')); + } +} diff --git a/app/Http/Livewire/Whatsapp/ShowProgramados.php b/app/Http/Livewire/Whatsapp/ShowProgramados.php new file mode 100644 index 0000000..b34f929 --- /dev/null +++ b/app/Http/Livewire/Whatsapp/ShowProgramados.php @@ -0,0 +1,100 @@ + 'required|string|max:20', + 'scheduledDate' => 'required|date', + 'scheduledTime' => 'required', + 'messageType' => 'required|in:text,template', + 'messageContent'=> 'required_if:messageType,text|nullable|string', + 'templateId' => 'required_if:messageType,template|nullable|integer', + ]; + } + + public function save(): void + { + $this->validate(); + + $template = $this->templateId ? WhatsappMessageTemplate::find($this->templateId) : null; + + WhatsappScheduledMessage::create([ + 'phone_number' => preg_replace('/\D/', '', $this->phoneNumber), + 'message_type' => $this->messageType, + 'message_content' => $this->messageContent, + 'template_id' => $this->templateId, + 'template_name' => $template?->template_name, + 'template_language' => $template?->language_code, + 'scheduled_date' => $this->scheduledDate, + 'scheduled_time' => $this->scheduledTime, + 'status' => 'pending', + 'created_by' => Auth::id(), + ]); + + $this->alert('success', 'Mensaje programado correctamente.'); + $this->resetForm(); + } + + public function cancel(int $id): void + { + WhatsappScheduledMessage::findOrFail($id)->update(['status' => 'cancelled']); + $this->alert('info', 'Mensaje cancelado.'); + } + + public function delete(int $id): void + { + WhatsappScheduledMessage::findOrFail($id)->delete(); + $this->alert('success', 'Registro eliminado.'); + } + + public function resetForm(): void + { + $this->showForm = false; + $this->phoneNumber = ''; + $this->messageType = 'text'; + $this->messageContent = ''; + $this->templateId = null; + $this->scheduledDate = ''; + $this->scheduledTime = ''; + } + + public function render() + { + $messages = WhatsappScheduledMessage::when($this->search, fn($q) => + $q->where('phone_number', 'like', "%{$this->search}%") + ->orWhere('message_content', 'like', "%{$this->search}%")) + ->when($this->filterStatus, fn($q) => $q->where('status', $this->filterStatus)) + ->orderByDesc('scheduled_date') + ->orderByDesc('scheduled_time') + ->paginate(20); + + $templates = WhatsappMessageTemplate::where('status', 'approved')->get(); + + return view('livewire.whatsapp.show-programados', compact('messages', 'templates')); + } +} diff --git a/app/Models/WhatsappConversation.php b/app/Models/WhatsappConversation.php new file mode 100644 index 0000000..6180545 --- /dev/null +++ b/app/Models/WhatsappConversation.php @@ -0,0 +1,28 @@ + 'boolean', + 'created_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(WhatsappUser::class, 'user_id'); + } +} diff --git a/app/Models/WhatsappMenu.php b/app/Models/WhatsappMenu.php new file mode 100644 index 0000000..75b28d2 --- /dev/null +++ b/app/Models/WhatsappMenu.php @@ -0,0 +1,34 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + public function options() + { + return $this->hasMany(WhatsappMenuOption::class, 'menu_id')->orderBy('sort_order'); + } + + public function parent() + { + return $this->belongsTo(WhatsappMenu::class, 'parent_id'); + } + + public function children() + { + return $this->hasMany(WhatsappMenu::class, 'parent_id'); + } +} diff --git a/app/Models/WhatsappMenuOption.php b/app/Models/WhatsappMenuOption.php new file mode 100644 index 0000000..dfb609f --- /dev/null +++ b/app/Models/WhatsappMenuOption.php @@ -0,0 +1,25 @@ + 'boolean', + ]; + + public function menu() + { + return $this->belongsTo(WhatsappMenu::class, 'menu_id'); + } +} diff --git a/app/Models/WhatsappMessageTemplate.php b/app/Models/WhatsappMessageTemplate.php new file mode 100644 index 0000000..fe46604 --- /dev/null +++ b/app/Models/WhatsappMessageTemplate.php @@ -0,0 +1,20 @@ + 'array', + 'example_parameters' => 'array', + ]; +} diff --git a/app/Models/WhatsappScheduledMessage.php b/app/Models/WhatsappScheduledMessage.php new file mode 100644 index 0000000..aa01e26 --- /dev/null +++ b/app/Models/WhatsappScheduledMessage.php @@ -0,0 +1,28 @@ + 'array', + 'scheduled_date' => 'date', + 'sent_at' => 'datetime', + ]; + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Models/WhatsappSystemConfig.php b/app/Models/WhatsappSystemConfig.php new file mode 100644 index 0000000..0ae0aba --- /dev/null +++ b/app/Models/WhatsappSystemConfig.php @@ -0,0 +1,26 @@ +first(); + return $record ? ($record->config_value ?? $default) : $default; + } + + public static function set(string $key, string $value): void + { + static::updateOrCreate( + ['config_key' => $key], + ['config_value' => $value] + ); + } +} diff --git a/app/Models/WhatsappUser.php b/app/Models/WhatsappUser.php new file mode 100644 index 0000000..3ef5b9a --- /dev/null +++ b/app/Models/WhatsappUser.php @@ -0,0 +1,44 @@ + 'array', + 'welcome_sent_at' => 'datetime', + 'bot_paused_until' => 'datetime', + 'terms_accepted_at' => 'datetime', + 'in_service' => 'boolean', + 'on_hold' => 'boolean', + 'advisor_requested' => 'boolean', + 'bot_enabled' => 'boolean', + 'terms_pending' => 'boolean', + ]; + + public function conversations() + { + return $this->hasMany(WhatsappConversation::class, 'user_id'); + } + + public function lastMessage() + { + return $this->hasOne(WhatsappConversation::class, 'user_id')->latestOfMany(); + } + + public function currentMenu() + { + return $this->belongsTo(WhatsappMenu::class, 'current_menu_id'); + } +} diff --git a/app/Models/WhatsappWebhookLog.php b/app/Models/WhatsappWebhookLog.php new file mode 100644 index 0000000..475cebd --- /dev/null +++ b/app/Models/WhatsappWebhookLog.php @@ -0,0 +1,17 @@ + 'datetime', + ]; +} diff --git a/database/migrations/2026_04_24_000001_create_whatsapp_users_table.php b/database/migrations/2026_04_24_000001_create_whatsapp_users_table.php new file mode 100644 index 0000000..dc195a5 --- /dev/null +++ b/database/migrations/2026_04_24_000001_create_whatsapp_users_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('phone_number', 20)->unique(); + $table->string('name', 100)->nullable(); + $table->enum('status', ['active', 'blocked', 'inactive'])->default('active'); + $table->unsignedBigInteger('current_menu_id')->nullable(); + $table->integer('current_step')->default(0); + $table->text('session_data')->nullable(); + $table->datetime('welcome_sent_at')->nullable(); + $table->tinyInteger('in_service')->default(0); + $table->unsignedBigInteger('in_service_by')->nullable(); + $table->tinyInteger('on_hold')->default(0); + $table->datetime('bot_paused_until')->nullable(); + $table->tinyInteger('advisor_requested')->default(0); + $table->tinyInteger('bot_enabled')->default(1); + $table->tinyInteger('terms_pending')->default(0); + $table->datetime('terms_accepted_at')->nullable(); + $table->unsignedBigInteger('terms_version_id')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_users'); + } +}; diff --git a/database/migrations/2026_04_24_000002_create_whatsapp_conversations_table.php b/database/migrations/2026_04_24_000002_create_whatsapp_conversations_table.php new file mode 100644 index 0000000..b42e9d1 --- /dev/null +++ b/database/migrations/2026_04_24_000002_create_whatsapp_conversations_table.php @@ -0,0 +1,41 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('message_id', 255)->nullable()->unique(); + $table->string('reply_to_message_id', 255)->nullable(); + $table->string('reaction_to_message_id', 255)->nullable(); + $table->string('reaction_emoji', 64)->nullable(); + $table->enum('direction', ['incoming', 'outgoing']); + $table->string('message_type', 32)->default('text'); + $table->text('content')->nullable(); + $table->text('media_url')->nullable(); + $table->string('whatsapp_media_id', 255)->nullable(); + $table->string('local_file', 255)->nullable(); + $table->string('local_thumb', 255)->nullable(); + $table->string('media_storage', 50)->nullable(); + $table->string('status', 32)->default('received'); + $table->tinyInteger('is_read')->default(0); + $table->string('filename', 255)->nullable(); + $table->string('mime_type', 100)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('user_id')->references('id')->on('whatsapp_users')->onDelete('cascade'); + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_conversations'); + } +}; diff --git a/database/migrations/2026_04_24_000003_create_whatsapp_menus_table.php b/database/migrations/2026_04_24_000003_create_whatsapp_menus_table.php new file mode 100644 index 0000000..848bd05 --- /dev/null +++ b/database/migrations/2026_04_24_000003_create_whatsapp_menus_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('name', 255); + $table->string('title', 255); + $table->text('message'); + $table->unsignedBigInteger('parent_id')->nullable(); + $table->tinyInteger('is_main')->default(0); + $table->tinyInteger('is_active')->default(1); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + + Schema::create('whatsapp_menu_options', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('menu_id'); + $table->integer('option_number'); + $table->string('title', 255); + $table->enum('action_type', ['submenu', 'message', 'template', 'url', 'advisor', 'flow']); + $table->text('action_value')->nullable(); + $table->tinyInteger('is_active')->default(1); + $table->integer('sort_order')->default(0); + + $table->foreign('menu_id')->references('id')->on('whatsapp_menus')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_menu_options'); + Schema::dropIfExists('whatsapp_menus'); + } +}; diff --git a/database/migrations/2026_04_24_000004_create_whatsapp_message_templates_table.php b/database/migrations/2026_04_24_000004_create_whatsapp_message_templates_table.php new file mode 100644 index 0000000..16c34c6 --- /dev/null +++ b/database/migrations/2026_04_24_000004_create_whatsapp_message_templates_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name', 255); + $table->string('template_name', 255); + $table->string('language_code', 10)->default('es'); + $table->enum('status', ['pending', 'approved', 'rejected', 'paused'])->default('pending'); + $table->text('body_text')->nullable(); + $table->enum('header_type', ['none', 'text', 'image', 'video', 'document'])->default('none'); + $table->string('header_text', 255)->nullable(); + $table->string('footer_text', 255)->nullable(); + $table->longText('components')->nullable(); + $table->longText('example_parameters')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_message_templates'); + } +}; diff --git a/database/migrations/2026_04_24_000005_create_whatsapp_system_config_table.php b/database/migrations/2026_04_24_000005_create_whatsapp_system_config_table.php new file mode 100644 index 0000000..3b59b62 --- /dev/null +++ b/database/migrations/2026_04_24_000005_create_whatsapp_system_config_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('config_key', 255)->unique(); + $table->text('config_value')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + // Seed default config keys + \DB::table('whatsapp_system_config')->insert([ + ['config_key' => 'whatsapp_token', 'config_value' => '', 'description' => 'Token de acceso Bearer de Meta', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'phone_number_id', 'config_value' => '', 'description' => 'ID del número de teléfono en Meta', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'whatsapp_api_url', 'config_value' => 'https://graph.facebook.com/v22.0/', 'description' => 'URL base de la Graph API', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'webhook_verify_token', 'config_value' => '', 'description' => 'Token secreto para verificar el webhook', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'bot_enabled', 'config_value' => '1', 'description' => 'Activar/desactivar el bot (1/0)', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'welcome_message', 'config_value' => '¡Hola! Bienvenido. ¿En qué te podemos ayudar?', 'description' => 'Mensaje de bienvenida', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'default_no_match', 'config_value' => 'No entendí tu mensaje. Escribe MENU para ver las opciones.', 'description' => 'Mensaje cuando no se entiende la entrada', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'advisor_message', 'config_value' => 'Un asesor se comunicará contigo pronto.', 'description' => 'Mensaje al solicitar asesor', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'business_hours_enabled', 'config_value' => '0', 'description' => 'Validar horarios de atención (1/0)', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'business_hours_start', 'config_value' => '08:00', 'description' => 'Hora inicio de atención', 'created_at' => now(), 'updated_at' => now()], + ['config_key' => 'business_hours_end', 'config_value' => '18:00', 'description' => 'Hora fin de atención', 'created_at' => now(), 'updated_at' => now()], + ]); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_system_config'); + } +}; diff --git a/database/migrations/2026_04_24_000006_create_whatsapp_scheduled_and_logs_table.php b/database/migrations/2026_04_24_000006_create_whatsapp_scheduled_and_logs_table.php new file mode 100644 index 0000000..43cd7f8 --- /dev/null +++ b/database/migrations/2026_04_24_000006_create_whatsapp_scheduled_and_logs_table.php @@ -0,0 +1,44 @@ +id(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('phone_number', 20); + $table->unsignedBigInteger('template_id')->nullable(); + $table->string('template_name', 100)->nullable(); + $table->string('template_language', 10)->nullable(); + $table->longText('template_parameters')->nullable(); + $table->enum('message_type', ['text', 'template'])->default('text'); + $table->text('message_content')->nullable(); + $table->date('scheduled_date'); + $table->time('scheduled_time'); + $table->enum('status', ['pending', 'sent', 'failed', 'cancelled'])->default('pending'); + $table->datetime('sent_at')->nullable(); + $table->text('error_message')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->datetime('created_at')->useCurrent(); + }); + + Schema::create('whatsapp_webhook_logs', function (Blueprint $table) { + $table->id(); + $table->longText('payload'); + $table->text('response')->nullable(); + $table->integer('status_code')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_webhook_logs'); + Schema::dropIfExists('whatsapp_scheduled_messages'); + } +}; diff --git a/public/DOCUMENTACION_BOT_WHATSAPP.md b/public/DOCUMENTACION_BOT_WHATSAPP.md new file mode 100644 index 0000000..bcd3677 --- /dev/null +++ b/public/DOCUMENTACION_BOT_WHATSAPP.md @@ -0,0 +1,916 @@ +# Documentación Técnica — Bot WhatsApp Cloud API + +> **Propósito:** Esta documentación está pensada para ser entregada a otra IA o equipo de desarrollo para reimplementar el bot en cualquier lenguaje/plataforma, con toda la información necesaria sobre flujos, APIs, base de datos y contratos de datos. + +--- + +## 1. Resumen del sistema + +El sistema es un **bot de WhatsApp** basado en la **WhatsApp Cloud API (Meta/Facebook Graph API v22.0)**. Permite: + +- Recibir y procesar mensajes entrantes mediante un **webhook HTTP**. +- Responder automáticamente con textos, menús, botones interactivos, listas y archivos multimedia. +- Transferir la conversación a un asesor humano. +- Enviar **plantillas de mensaje** aprobadas por Meta. +- Enviar **mensajes programados**. +- Gestionar **términos y condiciones** (envío y aceptación). + +**Lenguaje original:** PHP 8.x +**Base de datos:** MySQL / MariaDB +**Cola / Workers:** Worker PHP con cron (no RabbitMQ ni Redis, aunque se usa Redis como opción para rate-limit). + +--- + +## 2. Credenciales y configuración requerida + +Todas las credenciales se almacenan en la **tabla `system_config`** de la base de datos (no en código duro). También se cargan desde variables de entorno / archivo `.env`. + +### 2.1 Variables de entorno (`.env`) + +``` +DB_HOST=mysql # Host de la base de datos +DB_PORT=3306 +DB_NAME=usite_whatsapp_bot +DB_USER=usite_whatsapp_user +DB_PASS= +DB_CHARSET=utf8mb4 +``` + +### 2.2 Configuración de WhatsApp (en tabla `system_config`) + +| `config_key` | Descripción | Ejemplo | +|----------------------|-----------------------------------------------------------------|--------------------------------------------------| +| `whatsapp_token` | Token de acceso permanente de la App de Meta | `EAABz...` | +| `phone_number_id` | ID del número de teléfono de la cuenta de WhatsApp Business | `123456789012345` | +| `whatsapp_api_url` | URL base de la Graph API | `https://graph.facebook.com/v22.0/` | +| `webhook_verify_token` | Token secreto para verificar el webhook (GET de Meta) | `mi_token_secreto` | +| `bot_enabled` | Si el bot responde automáticamente (`1`/`0`) | `1` | +| `business_hours_*` | Horarios de atención (ver sección 8) | — | +| `terms_message` | Mensaje de términos y condiciones que se envía al usuario | texto libre | + +--- + +## 3. API de WhatsApp consumida + +**Base URL:** `https://graph.facebook.com/v22.0/{phone_number_id}/` + +**Autenticación:** Header `Authorization: Bearer {whatsapp_token}` + +**Formato:** JSON (Content-Type: `application/json`) salvo para subida de archivos (multipart/form-data). + +### 3.1 Enviar mensajes — `POST /{phone_number_id}/messages` + +Todos los tipos de mensaje tienen el campo común: + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX" +} +``` + +--- + +#### 3.1.1 Mensaje de texto + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "text", + "text": { + "body": "Hola, ¿en qué te podemos ayudar?" + } +} +``` + +--- + +#### 3.1.2 Mensaje de plantilla (template) + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "template", + "template": { + "name": "nombre_plantilla", + "language": { "code": "es" }, + "components": [ + { + "type": "header", + "parameters": [ + { "type": "text", "text": "Valor del header" } + ] + }, + { + "type": "body", + "parameters": [ + { "type": "text", "parameter_name": "nombre", "text": "Juan" }, + { "type": "text", "parameter_name": "fecha", "text": "20/04/2026" } + ] + } + ] + } +} +``` + +> **Nota sobre parámetros nombrados vs posicionales:** +> - Si la plantilla usa variables `{{nombre}}` (nombradas): incluir `"parameter_name": "nombre"`. +> - Si la plantilla usa variables `{{1}}`, `{{2}}` (posicionales): omitir `parameter_name`, usar solo `"type": "text", "text": "valor"` en orden. + +--- + +#### 3.1.3 Mensaje interactivo — Botones + +```json +{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "57300XXXXXXX", + "type": "interactive", + "interactive": { + "type": "button", + "header": { "type": "text", "text": "Encabezado opcional" }, + "body": { "text": "Selecciona una opción:" }, + "footer": { "text": "Pie de página opcional" }, + "action": { + "buttons": [ + { "type": "reply", "reply": { "id": "btn_1", "title": "Opción 1" } }, + { "type": "reply", "reply": { "id": "btn_2", "title": "Opción 2" } } + ] + } + } +} +``` + +> Máximo **3 botones**. El `title` no puede superar 20 caracteres. + +--- + +#### 3.1.4 Mensaje interactivo — Lista + +```json +{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "57300XXXXXXX", + "type": "interactive", + "interactive": { + "type": "list", + "body": { "text": "Elige una opción de la lista:" }, + "action": { + "button": "Ver opciones", + "sections": [ + { + "title": "Sección 1", + "rows": [ + { "id": "row_1", "title": "Opción 1", "description": "Descripción opcional" }, + { "id": "row_2", "title": "Opción 2" } + ] + } + ] + } + } +} +``` + +--- + +#### 3.1.5 Mensaje de imagen + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "image", + "image": { + "link": "https://ejemplo.com/imagen.jpg", + "caption": "Pie de foto opcional" + } +} +``` + +--- + +#### 3.1.6 Mensaje de video + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "video", + "video": { + "link": "https://ejemplo.com/video.mp4", + "caption": "Descripción del video" + } +} +``` + +--- + +#### 3.1.7 Mensaje de audio + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "audio", + "audio": { + "link": "https://ejemplo.com/audio.mp3" + } +} +``` + +> Para notas de voz, el archivo debe ser `.ogg` con códec **OPUS** y subirse primero al media endpoint. + +--- + +#### 3.1.8 Mensaje de documento + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "document", + "document": { + "link": "https://ejemplo.com/archivo.pdf", + "filename": "resultados.pdf", + "caption": "Tus resultados de laboratorio" + } +} +``` + +--- + +#### 3.1.9 Marcar mensaje como leído — `POST /{phone_number_id}/conversations` + +```json +{ + "messaging_product": "whatsapp", + "status": "read", + "message_id": "wamid.XXXXX" +} +``` + +--- + +#### 3.1.10 Enviar reacción a un mensaje + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "reaction", + "reaction": { + "message_id": "wamid.XXXXX", + "emoji": "👍" + } +} +``` + +--- + +### 3.2 Subir archivo multimedia — `POST /{phone_number_id}/media` + +**Content-Type:** `multipart/form-data` + +Campos del formulario: + +| Campo | Valor | +|---------------------|------------------------------------------| +| `messaging_product` | `whatsapp` | +| `file` | Archivo binario | +| `type` | MIME type (ej: `image/jpeg`, `video/mp4`) | + +**Respuesta exitosa:** +```json +{ "id": "1234567890123456" } +``` + +Una vez obtenido el `id`, se puede usar en mensajes así: + +```json +{ + "messaging_product": "whatsapp", + "to": "57300XXXXXXX", + "type": "image", + "image": { "id": "1234567890123456" } +} +``` + +--- + +### 3.3 Obtener URL de un media — `GET /{media_id}` + +**Respuesta:** +```json +{ + "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...", + "mime_type": "image/jpeg", + "sha256": "...", + "file_size": 12345, + "id": "1234567890123456", + "messaging_product": "whatsapp" +} +``` + +Luego descargar esa URL con el mismo Bearer token. + +--- + +### 3.4 Gestión de plantillas — `GET /{waba_id}/message_templates` + +``` +GET https://graph.facebook.com/v22.0/{WABA_ID}/message_templates +Authorization: Bearer {token} +``` + +**Parámetros opcionales:** `?name=nombre_plantilla&status=APPROVED` + +--- + +## 4. Webhook — Recepción de mensajes + +### 4.1 Verificación del webhook (GET) + +Meta envía un GET para verificar el endpoint: + +``` +GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE +``` + +El sistema debe responder con el valor de `hub.challenge` si `hub.verify_token` coincide con `WEBHOOK_VERIFY_TOKEN`. + +--- + +### 4.2 Payload de mensaje entrante (POST) + +```json +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "WABA_ID", + "changes": [ + { + "field": "messages", + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "57300XXXXXXX", + "phone_number_id": "PHONE_NUMBER_ID" + }, + "contacts": [ + { + "profile": { "name": "Nombre del Contacto" }, + "wa_id": "57300XXXXXXX" + } + ], + "messages": [ + { + "from": "57300XXXXXXX", + "id": "wamid.XXXXXXXXXXXXX", + "timestamp": "1713900000", + "type": "text", + "text": { "body": "Hola" } + } + ] + } + } + ] + } + ] +} +``` + +**Tipos de mensaje posibles en el campo `type`:** + +| `type` | Estructura del mensaje | +|--------------|-------------------------------------------------------------| +| `text` | `message.text.body` | +| `image` | `message.image.id`, `message.image.mime_type` | +| `video` | `message.video.id`, `message.video.mime_type` | +| `audio` | `message.audio.id`, `message.audio.voice` (bool) | +| `document` | `message.document.id`, `document.filename`, `document.mime_type` | +| `sticker` | `message.sticker.id` | +| `location` | `message.location.latitude`, `message.location.longitude` | +| `interactive`| `message.interactive.type` = `list_reply` o `button_reply` | +| `reaction` | `message.reaction.emoji`, `message.reaction.message_id` | + +**Respuesta de acceso interactivo:** + +```json +{ + "interactive": { + "type": "list_reply", + "list_reply": { + "id": "row_1", + "title": "Opción 1", + "description": "descripción" + } + } +} +``` + +--- + +### 4.3 Normalización interna de mensajes interactivos + +El webhook normaliza las respuestas de lista/botón a texto plano para que el BotService las procese como si fueran texto: + +- Si `title` empieza con un número (`"1. Opción"`), se extrae solo el número. +- Si no, se usa el `title` completo. + +--- + +## 5. Flujo de procesamiento de mensajes + +``` +WhatsApp Cloud API + │ + ▼ +POST /api/webhook.php + │ + ├─ Verificar duplicado (por message_id en conversations) + ├─ Obtener/crear usuario en tabla `users` + ├─ Guardar nombre del contacto si no tenía + │ + ▼ +WhatsAppWebhook::processConversations() + │ + ├─ Extraer tipo de mensaje y contenido + ├─ Guardar mensaje en tabla `conversations` (direction: incoming) + │ + ▼ +BotService::processMessage($user, $messageText, $messageType) + │ + ├─ 1. Adquirir advisory lock por usuario (MySQL GET_LOCK) → evita race conditions + ├─ 2. Si usuario bloqueado → descartar + ├─ 3. Si usuario in_service (atendido por asesor) → descartar + ├─ 4. maybeResetConversationAfterIdle() → reinicia si inactividad > 6h + ├─ 5. Flujo de Términos: + │ ├─ Si terms_pending → processTermsResponse() + │ └─ Si needsTermsAcceptance() → sendTermsMessage() + ├─ 6. Si 'isNewUser' → sendWelcomeMessage() + ├─ 7. Si usuario escribe "enviado/a/os/as" → requestAdvisor() + ├─ 8. processSpecialCommands() (MENU, ATRÁS, ASESOR, etc.) + ├─ 9. Si on_hold activo y no expiró → notificar y retornar + ├─10. Si advisor_requested activo → solo navegación explícita + ├─11. Si bot_paused_until activo → solo navegación explícita + ├─12. Si bot_enabled=false → solo comandos explícitos + ├─13. Si current_menu_id → processMenuSelection() + └─14. Fallback → sendDefaultNoMatch() +``` + +--- + +## 6. Comandos especiales del bot + +Los usuarios pueden escribir estas palabras clave en cualquier momento: + +| Palabra clave | Acción | +|----------------------------------------|--------------------------------------------| +| `menu`, `menú`, `inicio` | Mostrar menú principal | +| `atras`, `atrás`, `volver`, `back` | Retroceder al menú anterior | +| `asesor`, `agente`, `humano` | Solicitar atención humana | +| `enviado`, `enviada`, `enviados`, `enviadas` | Indicar que se envió documentación — transfiere a asesor | +| Número (`1`, `2`, `3`…) | Seleccionar opción de menú activo | + +--- + +## 7. Base de datos — Tablas principales del bot + +### 7.1 `users` — Usuarios (contactos de WhatsApp) + +| Campo | Tipo | Descripción | +|---------------------|-----------------------|----------------------------------------------------| +| `id` | INT AUTO_INCREMENT | Clave primaria | +| `phone_number` | VARCHAR(20) UNIQUE | Número en formato internacional sin `+` (ej: `573001234567`) | +| `name` | VARCHAR(100) | Nombre del contacto (viene del perfil de WhatsApp) | +| `status` | ENUM | `active`, `blocked`, `inactive` | +| `current_menu_id` | INT NULL | FK → `menus.id` — menú donde está el usuario | +| `current_step` | INT DEFAULT 0 | Paso dentro del flujo actual | +| `session_data` | TEXT (JSON) | Datos temporales de sesión | +| `welcome_sent_at` | DATETIME | Última vez que se envió el mensaje de bienvenida | +| `in_service` | TINYINT(1) | `1` = está siendo atendido por un asesor | +| `in_service_by` | INT NULL | FK → `admin_users.id` — asesor que lo atiende | +| `on_hold` | TINYINT(1) | `1` = el asesor lo puso en espera | +| `bot_paused_until` | DATETIME NULL | El bot está pausado hasta esta fecha/hora | +| `advisor_requested` | TINYINT(1) | `1` = el usuario solicitó asesor | +| `bot_enabled` | TINYINT(1) DEFAULT 1 | Si el bot responde a este usuario | +| `terms_pending` | TINYINT(1) | `1` = esperando respuesta de términos | +| `terms_accepted_at` | DATETIME | Cuándo aceptó los términos | +| `terms_version_id` | INT NULL | FK → `terms_versions.id` | +| `created_at` | TIMESTAMP | Fecha de creación | +| `updated_at` | TIMESTAMP | Última actualización | + +--- + +### 7.2 `conversations` — Mensajes + +| Campo | Tipo | Descripción | +|--------------------------|-----------------|-----------------------------------------------------------| +| `id` | INT | Clave primaria | +| `user_id` | INT | FK → `users.id` | +| `message_id` | VARCHAR(255) | ID único del mensaje en WhatsApp (`wamid.XXX`) | +| `reply_to_message_id` | VARCHAR(255) | ID del mensaje al que responde | +| `reaction_to_message_id` | VARCHAR(255) | ID del mensaje al que se reaccionó | +| `reaction_emoji` | VARCHAR(64) | Emoji de la reacción | +| `direction` | ENUM | `incoming` (usuario→bot) / `outgoing` (bot→usuario) | +| `message_type` | VARCHAR(32) | `text`, `image`, `video`, `audio`, `document`, `reaction`| +| `content` | TEXT | Contenido del mensaje (texto o JSON para multimedia/sistema) | +| `media_url` | TEXT | URL pública del archivo | +| `whatsapp_media_id` | VARCHAR(255) | ID del media en WhatsApp | +| `local_file` | VARCHAR(255) | Ruta local del archivo descargado | +| `local_thumb` | VARCHAR(255) | Ruta local del thumbnail | +| `media_storage` | VARCHAR(50) | Dónde se almacena (`local`, `whatsapp`, etc.) | +| `status` | VARCHAR(32) | `received`, `sent`, `delivered`, `read`, `failed` | +| `is_read` | TINYINT(1) | Si el admin marcó la conversación como leída | +| `filename` | VARCHAR(255) | Nombre del archivo adjunto | +| `mime_type` | VARCHAR(100) | MIME type del archivo | +| `created_at` | TIMESTAMP | Fecha del mensaje | + +--- + +### 7.3 `menus` — Menús del bot + +| Campo | Tipo | Descripción | +|-----------------|---------------|----------------------------------------------------------| +| `id` | INT | Clave primaria | +| `name` | VARCHAR(255) | Nombre interno del menú | +| `title` | VARCHAR(255) | Título que se muestra al usuario | +| `message` | TEXT | Texto del menú completo | +| `parent_id` | INT NULL | FK → `menus.id` — menú padre (para navegación ATRÁS) | +| `is_main` | TINYINT(1) | `1` = es el menú principal | +| `is_active` | TINYINT(1) | `1` = activo | +| `sort_order` | INT | Orden de presentación | +| `created_at` | TIMESTAMP | | + +--- + +### 7.4 `menu_options` — Opciones de menú + +| Campo | Tipo | Descripción | +|-----------------|---------------|----------------------------------------------------------------| +| `id` | INT | Clave primaria | +| `menu_id` | INT | FK → `menus.id` | +| `option_number` | INT | Número que el usuario debe escribir (1, 2, 3…) | +| `title` | VARCHAR(255) | Texto de la opción | +| `action_type` | ENUM | `submenu`, `message`, `template`, `url`, `advisor`, `flow` | +| `action_value` | TEXT | Depends en `action_type`: ID de submenú, texto, nombre de plantilla, URL | +| `is_active` | TINYINT(1) | `1` = activa | +| `sort_order` | INT | Orden | + +--- + +### 7.5 `message_templates` — Plantillas + +| Campo | Tipo | Descripción | +|----------------------|---------------|----------------------------------------------------------------| +| `id` | INT | Clave primaria | +| `name` | VARCHAR(255) | Nombre interno | +| `template_name` | VARCHAR(255) | Nombre en Meta (slug, sin espacios) | +| `language_code` | VARCHAR(10) | Idioma (`es`, `en`, etc.) | +| `status` | ENUM | `pending`, `approved`, `rejected`, `paused` | +| `body_text` | TEXT | Cuerpo del template con variables `{{1}}` o `{{nombre}}` | +| `header_type` | ENUM | `text`, `image`, `video`, `document` | +| `header_text` | VARCHAR(255) | Texto del header si aplica | +| `footer_text` | VARCHAR(255) | Pie del mensaje | +| `components` | LONGTEXT JSON | Componentes completos del template tal como los devuelve Meta | +| `example_parameters` | LONGTEXT JSON | Ejemplos de valores para las variables | +| `created_at` | TIMESTAMP | | +| `updated_at` | TIMESTAMP | | + +--- + +### 7.6 `autoresponses` — Respuestas automáticas por keyword + +| Campo | Tipo | Descripción | +|-----------------|-|-----------------------------------------------------------------| +| `id` | INT | Clave primaria | +| `trigger_type` | ENUM | `keyword`, `contains`, `exact`, `welcome`, `default` | +| `trigger_value` | TEXT | Palabras clave separadas por coma | +| `response_text` | TEXT | Texto de la respuesta | +| `response_type` | ENUM | `text`, `template`, `menu` | +| `template_name` | VARCHAR(255) | Nombre de la plantilla si `response_type = template` | +| `menu_id` | INT NULL | ID del menú si `response_type = menu` | +| `priority` | INT | Prioridad (mayor número = mayor prioridad) | +| `is_active` | TINYINT(1) | `1` = activa | + +--- + +### 7.7 `scheduled_messages` — Mensajes programados + +| Campo | Tipo | Descripción | +|-----------------------|---------------|-----------------------------------------------------------| +| `id` | INT | Clave primaria | +| `user_id` | INT | FK → `users.id` | +| `template_id` | INT NULL | FK → `message_templates.id` | +| `template_name` | VARCHAR(100) | Nombre de la plantilla | +| `template_language` | VARCHAR(10) | Idioma de la plantilla | +| `template_parameters` | LONGTEXT JSON | Parámetros para las variables de la plantilla | +| `message_type` | ENUM | `text`, `template` | +| `message_content` | TEXT | Texto si `message_type = text` | +| `scheduled_date` | DATE | Fecha programada | +| `scheduled_time` | TIME | Hora programada | +| `status` | ENUM | `pending`, `sent`, `failed`, `cancelled` | +| `sent_at` | DATETIME | Cuándo se envió efectivamente | +| `error_message` | TEXT | Error si falló el envío | +| `created_by` | INT NULL | Admin que creó el recordatorio | +| `created_at` | DATETIME | | + +--- + +### 7.8 `system_config` — Configuración del sistema + +| Campo | Tipo | Descripción | +|----------------|---------------|------------------------------------| +| `id` | INT | Clave primaria | +| `config_key` | VARCHAR(255) | Clave de configuración (UNIQUE) | +| `config_value` | TEXT | Valor de la configuración | +| `description` | TEXT NULL | Descripción opcional | +| `created_at` | DATETIME | | +| `updated_at` | DATETIME | | + +**Claves relevantes para el bot:** + +| `config_key` | Descripción | +|--------------------------|------------------------------------------------------| +| `whatsapp_token` | Token de acceso Bearer | +| `phone_number_id` | ID del número de teléfono | +| `whatsapp_api_url` | URL base del API | +| `webhook_verify_token` | Token secreto del webhook | +| `bot_enabled` | Activar/desactivar el bot (`1`/`0`) | +| `welcome_message` | Texto del mensaje de bienvenida | +| `default_no_match` | Mensaje cuando no se entiende la entrada del usuario| +| `advisor_message` | Mensaje al solicitar asesor | +| `terms_message` | Texto de los términos y condiciones | +| `business_hours_enabled` | Si se validan horarios de atención | +| `business_hours_start` | Hora inicio (ej: `06:15`) | +| `business_hours_end` | Hora fin (ej: `17:00`) | + +--- + +### 7.9 `notifications` — Notificaciones internas para asesores + +| Campo | Tipo | Descripción | +|-------------|--------------|------------------------------------------| +| `id` | INT | Clave primaria | +| `user_id` | INT | FK → `users.id` | +| `type` | VARCHAR(50) | Tipo de notificación (`new_message`, `advisor_requested`, etc.) | +| `message` | TEXT | Cuerpo de la notificación | +| `is_read` | TINYINT(1) | Si fue leída | +| `created_at`| TIMESTAMP | | + +--- + +### 7.10 `terms_versions` — Versiones de términos y condiciones + +| Campo | Tipo | Descripción | +|--------------------|---------------|-------------------------------------------------------| +| `id` | INT | Clave primaria | +| `version` | VARCHAR(20) | Versión (ej: `1.0`, `2.0`) | +| `titulo` | VARCHAR(255) | Título del documento | +| `documento_url` | TEXT | URL pública del PDF de términos | +| `mensaje_aceptacion` | TEXT | Mensaje que se envía al usuario solicitando aceptación | +| `activa` | TINYINT(1) | `1` = versión vigente | +| `forzar_reenvio` | TINYINT(1) | `1` = forzar que todos los usuarios re-acepten | +| `created_at` | TIMESTAMP | | + +--- + +### 7.11 `terms_acceptance` — Aceptación de términos + +| Campo | Tipo | Descripción | +|--------------------|---------------|---------------------------------------| +| `id` | INT | Clave primaria | +| `user_id` | INT | FK → `users.id` | +| `terms_version_id` | INT | FK → `terms_versions.id` | +| `phone_number` | VARCHAR(20) | Teléfono del usuario | +| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado` | +| `fecha_envio` | DATETIME | Cuándo se envió la solicitud | +| `fecha_respuesta` | DATETIME NULL | Cuándo respondió el usuario | + +--- + +### 7.12 `admin_users` — Administradores del sistema + +| Campo | Tipo | Descripción | +|----------------|---------------|----------------------------------| +| `id` | INT | Clave primaria | +| `username` | VARCHAR(50) | Nombre de usuario (UNIQUE) | +| `password_hash`| VARCHAR(255) | Bcrypt hash de la contraseña | +| `full_name` | VARCHAR(100) | Nombre completo | +| `email` | VARCHAR(100) | Correo electrónico | +| `is_active` | TINYINT(1) | `1` = activo | +| `role_id` | INT NULL | FK → `roles.id` | +| `last_login` | TIMESTAMP | Último login | + +--- + +### 7.13 `webhook_logs` — Logs del webhook + +| Campo | Tipo | Descripción | +|---------------|---------------|---------------------------------------| +| `id` | INT | Clave primaria | +| `payload` | LONGTEXT | Body JSON recibido de WhatsApp | +| `response` | TEXT | Respuesta devuelta | +| `status_code` | INT | Código HTTP | +| `created_at` | TIMESTAMP | Fecha del evento | + +--- + +## 8. Formato del número de teléfono + +Los números se almacenan y envían **sin el símbolo `+`**, en formato E.164: + +- Colombia: `573001234567` (código país 57 + número sin prefijo) +- Argentina: `5491123456789` + +El sistema normaliza automáticamente: +- Elimina caracteres no numéricos. +- Si el número tiene 10 dígitos y empieza con `3` → agrega prefijo `57` (Colombia). +- Si ya tiene 12 dígitos → lo usa tal cual. + +--- + +## 9. Lógica de estados del usuario + +El bot controla el flujo mediante campos en la tabla `users`: + +``` +Estado normal: + bot_enabled=1, in_service=0, on_hold=0, advisor_requested=0, bot_paused_until=NULL + → Bot responde normalmente + +En menú: + current_menu_id= → Bot espera número de opción + +Atendido por asesor: + in_service=1, in_service_by= + → Bot NO responde, asesor humano escribe directamente + +En espera (hold activo): + on_hold=1, bot_paused_until= + → Bot notifica que está en espera, solo permite MENU/ATRÁS/números + +Asesor solicitado: + advisor_requested=1, bot_paused_until= + → Bot espera al asesor, solo permite navegación explícita + +Bot pausado: + bot_paused_until= + → Bot no responde mensajes libres, solo menús/comandos + +Pendiente de términos: + terms_pending=1 + → Bot solo procesa respuesta "acepto" o "rechazo" + +Usuario bloqueado: + status='blocked' + → Todos los mensajes se descartan silenciosamente +``` + +--- + +## 10. Estados de conversación (tabla `user_states`) + +Estados clave/valor persistentes por usuario (como sesión): + +| `state_key` | Descripción | +|----------------|---------------------------------------------------------------| +| `menu_history` | Array JSON con historial de IDs de menús visitados | +| `current_menu` | ID del menú actual | +| `form_data` | Datos temporales de un formulario en curso | + +--- + +## 11. Endpoints internos del sistema (API interna) + +Estos endpoints son para el **panel de administración**, no son de WhatsApp. Se implementarían como API REST en el nuevo sistema: + +| Método | Ruta interna | Descripción | +|--------|-------------------------------|--------------------------------------------------| +| GET | `/api/get_conversations.php` | Lista de conversaciones con paginación | +| GET | `/api/get_conversation_detail.php?user_id=X` | Mensajes de un usuario | +| POST | `/api/send_message.php` | Enviar mensaje desde el panel | +| POST | `/api/send_reply.php` | Responder a un mensaje | +| POST | `/api/attend.php` | Marcar usuario como "en atención" por asesor | +| POST | `/api/finish_attend.php` | Liberar usuario de la atención | +| POST | `/api/release_hold.php` | Liberar usuario del estado on_hold | +| GET | `/api/get_templates.php` | Lista de plantillas aprobadas | +| POST | `/api/send_broadcast.php` | Enviar mensaje masivo a múltiples usuarios | +| POST | `/api/schedule_message.php` | Programar un mensaje | +| GET | `/api/get_menus.php` | Lista de menús configurados | +| POST | `/api/save_menu.php` | Crear/editar menú | +| GET | `/api/get_settings.php` | Configuración del sistema | +| POST | `/api/save_settings.php` | Guardar configuración | +| GET | `/api/sse_events.php` | Server-Sent Events para tiempo real | + +--- + +## 12. Flujo de términos y condiciones + +``` +Usuario envía mensaje + │ + ▼ +¿needsTermsAcceptance()? + │ SÍ + ▼ +sendTermsMessage() + → Envía texto con el mensaje de términos + URL del documento + → Guarda users.terms_pending = 1 + → Inserta en terms_acceptance (estado: 'pendiente') + │ +Usuario responde + │ +processTermsResponse() + ├─ Si respuesta contiene "acepto", "si", "sí", "ok", "1" → ACEPTA + │ → Actualiza users.terms_accepted_at, terms_version_id + │ → Actualiza terms_acceptance.estado = 'aceptado' + │ → Envía mensaje de confirmación + └─ Si respuesta contiene "no", "rechazo", "2" → RECHAZA + → Actualiza terms_acceptance.estado = 'rechazado' + → Envía mensaje de rechazo +``` + +--- + +## 13. Worker de mensajes programados + +Un proceso cron (o worker) ejecuta cada minuto: + +1. Consulta `scheduled_messages` donde `status = 'pending'` y `scheduled_date + scheduled_time <= NOW()`. +2. Para cada registro: + - Si `message_type = 'template'` → llama a `WhatsAppService::sendTemplateMessage()`. + - Si `message_type = 'text'` → llama a `WhatsAppService::sendTextMessage()`. +3. Actualiza `status = 'sent'` y registra `sent_at`. +4. Si falla → `status = 'failed'` + registra `error_message`. + +--- + +## 14. Diagrama de clases del bot + +``` +WhatsAppWebhook (api/webhook.php) + │ + ├──uses──▶ WhatsAppService (services/WhatsAppService.php) + │ ├── sendTextMessage($to, $message) + │ ├── sendTemplateMessage($to, $name, $lang, $bodyParams, $headerParams, $rawComponents) + │ ├── sendInteractiveMessage($to, $body, $buttons, $header, $footer) + │ ├── sendListMessage($to, $body, $buttonText, $sections, $header, $footer) + │ ├── sendImageMessage($to, $url, $caption) + │ ├── sendVideoMessage($to, $url, $caption) + │ ├── sendAudioMessage($to, $url) + │ ├── sendDocumentMessage($to, $url, $filename, $caption) + │ ├── sendReactionMessage($to, $messageId, $emoji) + │ ├── markAsRead($messageId) + │ ├── uploadMedia($filePath, $mimeType) → retorna { id: "..." } + │ └── getMediaUrl($mediaId) → retorna URL temporal + │ + └──uses──▶ BotService (services/BotService.php) + │ + ├──uses──▶ MenuService (services/MenuService.php) + │ └── getMenu($id), getMenuOptions($menuId) + │ + ├──uses──▶ ConversationStateService (services/ConversationStateService.php) + │ ├── getCurrentMenuId($phone) + │ ├── setCurrentMenu($phone, $menuId) + │ └── getStateData($phone, $key) + │ + ├──uses──▶ BusinessHoursService (services/BusinessHoursService.php) + │ └── isWithinBusinessHours() + │ + └──uses──▶ NLPService (services/NLPService.php) + └── analyze($text) → detección de intenciones básica +``` + +--- + +## 15. Checklist de implementación + +Para reimplementar el bot en otro sistema/lenguaje: + +- [ ] Configurar la App en Meta for Developers con permisos `whatsapp_business_messaging`, `whatsapp_business_management`. +- [ ] Obtener: `Access Token`, `Phone Number ID`, `WABA ID`, `Webhook Verify Token`. +- [ ] Crear endpoint público HTTPS para el webhook (GET para verificación, POST para mensajes). +- [ ] Crear las tablas de base de datos: `users`, `conversations`, `menus`, `menu_options`, `message_templates`, `autoresponses`, `system_config`, `notifications`, `terms_versions`, `terms_acceptance`, `scheduled_messages`, `webhook_logs`. +- [ ] Implementar `WhatsAppService` con los métodos de envío (ver sección 3). +- [ ] Implementar la lógica de estados del usuario (sección 9). +- [ ] Implementar el flujo de menús dinámicos (mensajes numerados). +- [ ] Implementar el flujo de términos y condiciones (sección 12). +- [ ] Implementar sistema de advisory lock por usuario para evitar mensajes duplicados en procesamiento concurrente. +- [ ] Implementar cron/worker para mensajes programados (sección 13). +- [ ] Implementar reinicio de sesión después de 6h de inactividad. + +--- + +## 16. Notas de seguridad + +- **Nunca** expongas el `whatsapp_token` en el frontend. +- Validar siempre el `hub.verify_token` en el handshake del webhook. +- Sanear todos los campos de texto antes de almacenar en BD para evitar XSS/SQLi. +- Los mensajes duplicados se detectan por `message_id` único (`wamid.XXX`) — siempre validar antes de procesar. +- Usar advisory locks a nivel de BD para evitar race conditions cuando WhatsApp envía el mismo webhook dos veces en paralelo. diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 5f700ff..703718e 100755 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -245,6 +245,50 @@ + {{-- Módulo WhatsApp (solo admins) --}} + @if (in_array(auth()->user()->rol->nombre, ['super', 'administrador'])) + + @endif +
diff --git a/resources/views/livewire/whatsapp/show-configuracion-bot.blade.php b/resources/views/livewire/whatsapp/show-configuracion-bot.blade.php new file mode 100644 index 0000000..51d7453 --- /dev/null +++ b/resources/views/livewire/whatsapp/show-configuracion-bot.blade.php @@ -0,0 +1,118 @@ +
+
+

+ + + + + Configuración del Bot WhatsApp +

+ + + + {{-- Credenciales Meta --}} +
+

Credenciales Meta / Graph API

+ +
+ + + @error('whatsapp_token')

{{ $message }}

@enderror +
+ +
+
+ + + @error('phone_number_id')

{{ $message }}

@enderror +
+
+ + + @error('whatsapp_api_url')

{{ $message }}

@enderror +
+
+ +
+ + + @error('webhook_verify_token')

{{ $message }}

@enderror +
+
+ + {{-- Control del bot --}} +
+

Control del Bot

+ +
+ + +
+ +
+ + + @error('welcome_message')

{{ $message }}

@enderror +
+ +
+ + + @error('default_no_match')

{{ $message }}

@enderror +
+ +
+ + + @error('advisor_message')

{{ $message }}

@enderror +
+
+ + {{-- Horarios --}} +
+

Horarios de Atención

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ +
+
diff --git a/resources/views/livewire/whatsapp/show-conversaciones.blade.php b/resources/views/livewire/whatsapp/show-conversaciones.blade.php new file mode 100644 index 0000000..ef696bf --- /dev/null +++ b/resources/views/livewire/whatsapp/show-conversaciones.blade.php @@ -0,0 +1,166 @@ +
+ + {{-- Panel izquierdo: lista de contactos --}} +
+ {{-- Header --}} +
+

+ + + + + WhatsApp +

+
+ +
+
+ + {{-- Lista de usuarios --}} +
+ @forelse($users as $user) + + @empty +
No hay contactos
+ @endforelse +
+ + {{-- Paginación --}} +
+ {{ $users->links() }} +
+
+ + {{-- Panel derecho: chat --}} +
+ @if($selectedUser) + {{-- Chat header --}} +
+
+ +
+
{{ $selectedUser->name ?? 'Sin nombre' }}
+
{{ $selectedUser->phone_number }}
+
+
+
+ @if($selectedUser->in_service) + En atención + @endif + @if($selectedUser->on_hold) + En espera + @endif + +
+
+ + {{-- Mensajes --}} +
+ + @forelse($messages as $msg) +
+
+ @if($msg->message_type === 'text') +

{{ $msg->content }}

+ @elseif(in_array($msg->message_type, ['image', 'video', 'document', 'audio'])) + @if($msg->media_url) + @if($msg->message_type === 'image') + Imagen + @elseif($msg->message_type === 'document') + + + {{ $msg->filename ?? 'Documento' }} + + @else + [{{ strtoupper($msg->message_type) }}] + @endif + @else + [{{ strtoupper($msg->message_type) }} sin URL] + @endif + @else +

{{ $msg->content }}

+ @endif +
+ {{ $msg->created_at->format('H:i') }} + @if($msg->direction === 'outgoing') + ✓✓ + @endif +
+
+
+ @empty +
No hay mensajes
+ @endforelse +
+ + {{-- Input de respuesta --}} +
+ @if($selectedUser->status === 'blocked') +
Usuario bloqueado — desbloquea para responder
+ @else +
+ + +
+ @error('replyText')

{{ $message }}

@enderror + @if(session('error'))

{{ session('error') }}

@endif + @endif +
+ @else +
+ + + +

Selecciona una conversación

+

Elige un contacto de la lista para ver el chat

+
+ @endif +
+
diff --git a/resources/views/livewire/whatsapp/show-logs-webhook.blade.php b/resources/views/livewire/whatsapp/show-logs-webhook.blade.php new file mode 100644 index 0000000..0f0d446 --- /dev/null +++ b/resources/views/livewire/whatsapp/show-logs-webhook.blade.php @@ -0,0 +1,82 @@ +
+
+

+ + + + Logs del Webhook +

+ +
+ + + +
+ + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + @empty + + @endforelse + +
IDFechaHTTPPayload (preview)Acciones
{{ $log->id }}{{ $log->created_at?->format('d/m/Y H:i:s') }} + @if($log->status_code) + + {{ $log->status_code }} + + @else + + @endif + + {{ Str::limit($log->payload, 120) }} + +
+ + +
+
No hay logs registrados
+
{{ $logs->links() }}
+
+ + {{-- Modal detalle payload --}} + @if($viewId) +
+
+
+

Log #{{ $viewId }} — Payload completo

+ +
+
{{ $viewPayload }}
+
+
+ @endif +
diff --git a/resources/views/livewire/whatsapp/show-menus-bot.blade.php b/resources/views/livewire/whatsapp/show-menus-bot.blade.php new file mode 100644 index 0000000..dcf701e --- /dev/null +++ b/resources/views/livewire/whatsapp/show-menus-bot.blade.php @@ -0,0 +1,250 @@ +
+
+

+ + + + Menús del Bot +

+ +
+ + {{-- Buscador --}} + + + {{-- Tabla de menús --}} +
+ + + + + + + + + + + + + @forelse($menus as $menu) + + + + + + + + + @empty + + @endforelse + +
NombreTítuloPrincipalActivoOpcionesAcciones
{{ $menu->name }}{{ $menu->title }} + @if($menu->is_main) + + @else + + @endif + + + {{ $menu->is_active ? 'Activo' : 'Inactivo' }} + + + + +
+ + + +
+
No hay menús creados
+
+ + {{-- Detalle opciones del menú seleccionado --}} + @if($viewMenu) +
+
+

Opciones del menú: {{ $viewMenu->name }}

+
+ + +
+
+ + + + + + + + + + + + + @forelse($viewMenu->options as $opt) + + + + + + + + + @empty + + @endforelse + +
#TítuloAcciónValorActivaOps
{{ $opt->option_number }}{{ $opt->title }} + {{ $opt->action_type }} + {{ $opt->action_value }} + {{ $opt->is_active ? '✓' : '✗' }} + +
+ + +
+
Sin opciones
+
+ @endif + + {{-- Modal: Formulario Menú --}} + @if($showMenuForm) +
+
+

{{ $editMenuId ? 'Editar Menú' : 'Nuevo Menú' }}

+
+
+
+ + + @error('menuName')

{{ $message }}

@enderror +
+
+ + + @error('menuTitle')

{{ $message }}

@enderror +
+
+
+ + + @error('menuMessage')

{{ $message }}

@enderror +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ @endif + + {{-- Modal: Formulario Opción --}} + @if($showOptionForm) +
+
+

{{ $editOptionId ? 'Editar Opción' : 'Nueva Opción' }}

+
+
+
+ + +
+
+ + + @error('optionTitle')

{{ $message }}

@enderror +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+
+
+ @endif +
diff --git a/resources/views/livewire/whatsapp/show-plantillas.blade.php b/resources/views/livewire/whatsapp/show-plantillas.blade.php new file mode 100644 index 0000000..3a1c7d5 --- /dev/null +++ b/resources/views/livewire/whatsapp/show-plantillas.blade.php @@ -0,0 +1,141 @@ +
+
+

+ + + + Plantillas de Mensaje +

+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + @forelse($templates as $tpl) + + + + + + + + + @empty + + @endforelse + +
NombreTemplate (Meta)IdiomaHeaderEstadoAcciones
{{ $tpl->name }}{{ $tpl->template_name }}{{ $tpl->language_code }}{{ $tpl->header_type }} + @php + $colors = ['approved'=>'green','pending'=>'yellow','rejected'=>'red','paused'=>'gray']; + $c = $colors[$tpl->status] ?? 'gray'; + @endphp + {{ $tpl->status }} + +
+ + +
+
No hay plantillas registradas
+
{{ $templates->links() }}
+
+ + {{-- Modal Formulario --}} + @if($showForm) +
+
+

{{ $editId ? 'Editar Plantilla' : 'Nueva Plantilla' }}

+
+
+
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + + @error('templateName')

{{ $message }}

@enderror +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if($headerType === 'text') +
+ + +
+ @endif +
+ + + @error('bodyText')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+
+
+ @endif +
diff --git a/resources/views/livewire/whatsapp/show-programados.blade.php b/resources/views/livewire/whatsapp/show-programados.blade.php new file mode 100644 index 0000000..e04312b --- /dev/null +++ b/resources/views/livewire/whatsapp/show-programados.blade.php @@ -0,0 +1,142 @@ +
+
+

+ + + + Mensajes Programados +

+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + @forelse($messages as $msg) + + + + + + + + + @empty + + @endforelse + +
TeléfonoTipoContenido / PlantillaFecha programadaEstadoAcciones
{{ $msg->phone_number }} + {{ $msg->message_type }} + + {{ $msg->message_type === 'text' ? Str::limit($msg->message_content, 50) : $msg->template_name }} + + {{ \Carbon\Carbon::parse($msg->scheduled_date)->format('d/m/Y') }} + {{ $msg->scheduled_time }} + + @php + $colors = ['pending'=>'yellow','sent'=>'green','failed'=>'red','cancelled'=>'gray']; + $c = $colors[$msg->status] ?? 'gray'; + @endphp + {{ $msg->status }} + +
+ @if($msg->status === 'pending') + + @endif + +
+
No hay mensajes programados
+
{{ $messages->links() }}
+
+ + {{-- Modal formulario --}} + @if($showForm) +
+
+

Programar Mensaje

+
+
+ + + @error('phoneNumber')

{{ $message }}

@enderror +
+
+
+ + + @error('scheduledDate')

{{ $message }}

@enderror +
+
+ + + @error('scheduledTime')

{{ $message }}

@enderror +
+
+
+ + +
+ @if($messageType === 'text') +
+ + + @error('messageContent')

{{ $message }}

@enderror +
+ @else +
+ + + @error('templateId')

{{ $message }}

@enderror +
+ @endif +
+ + +
+
+
+
+ @endif +
diff --git a/resources/views/whatsapp/configuracion.blade.php b/resources/views/whatsapp/configuracion.blade.php new file mode 100644 index 0000000..a1cee71 --- /dev/null +++ b/resources/views/whatsapp/configuracion.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/resources/views/whatsapp/conversaciones.blade.php b/resources/views/whatsapp/conversaciones.blade.php new file mode 100644 index 0000000..1cc669e --- /dev/null +++ b/resources/views/whatsapp/conversaciones.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/whatsapp/logs.blade.php b/resources/views/whatsapp/logs.blade.php new file mode 100644 index 0000000..458e111 --- /dev/null +++ b/resources/views/whatsapp/logs.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/resources/views/whatsapp/menus.blade.php b/resources/views/whatsapp/menus.blade.php new file mode 100644 index 0000000..070caf1 --- /dev/null +++ b/resources/views/whatsapp/menus.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/resources/views/whatsapp/plantillas.blade.php b/resources/views/whatsapp/plantillas.blade.php new file mode 100644 index 0000000..17d4775 --- /dev/null +++ b/resources/views/whatsapp/plantillas.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/resources/views/whatsapp/programados.blade.php b/resources/views/whatsapp/programados.blade.php new file mode 100644 index 0000000..787f70e --- /dev/null +++ b/resources/views/whatsapp/programados.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/routes/web.php b/routes/web.php index 643da27..d57644c 100755 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::any('/log-historial', [MenuController::class, 'showLogHistorial'])->name('logHistorial'); Route::get('/diagnostico', [MenuController::class, 'showDiagnostico'])->name('diagnostico'); Route::get('/utilidades', [MenuController::class, 'showutilidad'])->name('utilidades'); Route::get('/mantenimiento', [MenuController::class, 'mantenimiento'])->name('mantenimiento'); Route::get('/rank-management', [MenuController::class, 'showgestionrangos'])->name('gestion-rangos'); + + // Módulo WhatsApp + Route::prefix('whatsapp')->name('whatsapp.')->group(function () { + Route::get('/conversaciones', [WhatsappController::class, 'conversaciones'])->name('conversaciones'); + Route::get('/menus', [WhatsappController::class, 'menus'])->name('menus'); + Route::get('/plantillas', [WhatsappController::class, 'plantillas'])->name('plantillas'); + Route::get('/programados', [WhatsappController::class, 'programados'])->name('programados'); + Route::get('/configuracion', [WhatsappController::class, 'configuracion'])->name('configuracion'); + Route::get('/logs', [WhatsappController::class, 'logs'])->name('logs'); + }); }); Route::post('/registrar-accion-compra', [MenuController::class, 'click_compra'])->name('click_compra');