feat(whatsapp): mismo motor, menú y configuración que Telegram y el chat web

WhatsApp dejaba de usar su bot propio (árbol de menús sin login ni compras) y
pasa al motor compartido, así los tres canales quedan iguales.

- TelegramBotService recibe el canal por constructor; flujos, menús, Gemini,
  OCR, validación de pagos y datos bancarios salen de ChatConfig sin duplicar
- WhatsApp no tiene teclados inline: las opciones se envían numeradas y la
  respuesta numérica se traduce al callback equivalente
- estado en caché con prefijo por canal (Telegram conserva el suyo para no
  cerrar las sesiones abiertas)
- descarga de comprobantes y audios por la Graph API de Meta
- respuesta manual del asesor y aviso de vencimiento salen también por WhatsApp
- la pantalla de WhatsApp queda sólo con credenciales; el resto se administra
  en Chat / Bot

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-26 23:24:21 -05:00
co-authored by Claude Opus 5
parent e3ad0a423a
commit 5bc1ec14ca
10 changed files with 294 additions and 105 deletions
+3
View File
@@ -105,6 +105,9 @@ class Kernel extends ConsoleKernel
'parse_mode' => 'Markdown',
]);
}
} elseif ($contacto->canal === 'whatsapp' && $contacto->canal_id) {
(new \App\Services\TelegramBotService('whatsapp'))
->send($contacto->canal_id, $msgCanal);
} elseif ($contacto->canal === 'web') {
$conv = $contacto->activeConversation();
if ($conv) {
@@ -6,7 +6,7 @@ use App\Models\WhatsappConversation;
use App\Models\WhatsappSystemConfig;
use App\Models\WhatsappUser;
use App\Models\WhatsappWebhookLog;
use App\Services\WhatsappBotService;
use App\Services\TelegramBotService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
@@ -195,13 +195,39 @@ class WhatsappWebhookController extends Controller
'is_read' => false,
]);
$botService = new WhatsappBotService();
// Interruptor del módulo: con el bot apagado sólo se registra el mensaje.
if (WhatsappSystemConfig::get('bot_enabled', '1') !== '1') {
return;
}
// Mismo motor que Telegram y el chat web: idénticos menús, flujos y configuración.
$bot = new TelegramBotService('whatsapp');
// Confirmar lectura a WhatsApp
$botService->markRead($messageId);
$bot->waMarkRead($messageId);
// Procesar con el motor del bot
$botService->handle($user, $texto ?? '', $type);
switch ($type) {
case 'image':
case 'document':
if ($mediaId) {
$bot->handlePhoto($from, $mediaId);
return;
}
$bot->handleText($from, $texto ?? '', $nombre);
return;
case 'audio':
case 'voice':
if ($mediaId) {
$bot->handleVoice($from, $mediaId, $nombre);
return;
}
$bot->handleText($from, $texto ?? '', $nombre);
return;
default:
$bot->handleText($from, $texto ?? '', $nombre);
}
}
// ──────────────────────────────────────────────────────────
@@ -83,10 +83,13 @@ class ShowConversaciones extends Component
$conv->update(['ultimo_mensaje_at' => now()]);
// Enviar via Telegram si el canal es telegram
// Entregar por el canal del cliente
if ($conv->canal === 'telegram') {
$engine = new ChatBotEngine();
$engine->enviarTelegram($conv->contact->canal_id, $this->replyText);
} elseif ($conv->canal === 'whatsapp') {
(new \App\Services\TelegramBotService('whatsapp'))
->send($conv->contact->canal_id, $this->replyText);
}
$this->replyText = '';
@@ -16,12 +16,6 @@ class ShowConfiguracionBot extends Component
public string $webhook_verify_token = '';
public string $app_secret = '';
public string $bot_enabled = '1';
public string $welcome_message = '';
public string $default_no_match = '';
public string $advisor_message = '';
public string $business_hours_enabled = '0';
public string $business_hours_start = '';
public string $business_hours_end = '';
protected $rules = [
'whatsapp_token' => 'required|string',
@@ -29,18 +23,13 @@ class ShowConfiguracionBot extends Component
'whatsapp_api_url' => 'required|url',
'webhook_verify_token' => 'required|string',
'app_secret' => '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', 'app_secret', 'bot_enabled', 'welcome_message',
'default_no_match', 'advisor_message', 'business_hours_enabled',
'business_hours_start', 'business_hours_end',
'webhook_verify_token', 'app_secret', 'bot_enabled',
];
foreach ($keys as $key) {
@@ -54,9 +43,7 @@ class ShowConfiguracionBot extends Component
$keys = [
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
'webhook_verify_token', 'app_secret', 'bot_enabled', 'welcome_message',
'default_no_match', 'advisor_message', 'business_hours_enabled',
'business_hours_start', 'business_hours_end',
'webhook_verify_token', 'app_secret', 'bot_enabled',
];
foreach ($keys as $key) {
+9 -5
View File
@@ -26,15 +26,19 @@ class ValidarPagoTelegramJob implements ShouldQueue
public function __construct(
private string $chatId,
private array $datosPago,
private int $intento = 1
private int $intento = 1,
private string $canal = 'telegram'
) {}
public function handle(): void
{
$cacheKey = "tgbot_state_{$this->chatId}";
// Telegram conserva su prefijo histórico de caché; los demás canales usan el suyo.
$cacheKey = $this->canal === 'telegram'
? "tgbot_state_{$this->chatId}"
: "{$this->canal}bot_state_{$this->chatId}";
$state = Cache::get($cacheKey, []);
$usuarioId = $state['user_id'] ?? null;
$bot = new TelegramBotService();
$bot = new TelegramBotService($this->canal);
if (! $usuarioId) {
return;
@@ -49,7 +53,7 @@ class ValidarPagoTelegramJob implements ShouldQueue
$nombreUsuario = $state['data']['nombre_remitente'] ?? null;
$resultado = app(PagoValidadorService::class)->validar(
$this->datosPago, $usuarioId, $nombreUsuario, 'telegram'
$this->datosPago, $usuarioId, $nombreUsuario, $this->canal
);
if ($resultado['estado'] === 'confirmado') {
@@ -68,7 +72,7 @@ class ValidarPagoTelegramJob implements ShouldQueue
// No encontrado — reintentar o agotar
if ($this->intento < self::MAX_INTENTOS) {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intento {$this->intento}/" . self::MAX_INTENTOS . " — reintentando en 20s");
self::dispatch($this->chatId, $this->datosPago, $this->intento + 1)
self::dispatch($this->chatId, $this->datosPago, $this->intento + 1, $this->canal)
->delay(now()->addSeconds(20));
} else {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intentos agotados, mostrando botón manual");
+2
View File
@@ -31,6 +31,7 @@ class ChatConversation extends Model
{
return match($this->canal) {
'telegram' => '✈️ Telegram',
'whatsapp' => '🟢 WhatsApp',
default => '💬 Web',
};
}
@@ -39,6 +40,7 @@ class ChatConversation extends Model
{
return match($this->canal) {
'telegram' => 'bg-blue-100 text-blue-700',
'whatsapp' => 'bg-emerald-100 text-emerald-700',
default => 'bg-green-100 text-green-700',
};
}
+206 -16
View File
@@ -40,6 +40,17 @@ class TelegramBotService
private ?int $convId = null;
/**
* Canal de transporte: 'telegram' | 'whatsapp'.
* Los flujos, menús y configuración son los mismos; sólo cambia el envío.
*/
private string $canal;
public function __construct(string $canal = 'telegram')
{
$this->canal = $canal;
}
// ─── Entry points ─────────────────────────────────────────
public function handleText(string $chatId, string $text, string $nombre = ''): void
@@ -97,8 +108,23 @@ class TelegramBotService
$step = $state['step'] ?? 'await_email';
// Pasos que esperan texto libre del usuario: ahí un número es un dato, no una opción.
$pasosDeTexto = ['await_email', 'await_otp', 'await_nombre', 'await_celular',
'await_amount', 'await_remitente_nombre'];
// WhatsApp responde con el número de la opción; se traduce al callback equivalente.
if ($this->canal === 'whatsapp' && ! in_array($step, $pasosDeTexto, true)
&& preg_match('/^\d{1,2}$/', $lower)) {
$mapa = Cache::get($this->kbKey($chatId), []);
if (isset($mapa[$lower])) {
$this->handleCallback($chatId, '', $mapa[$lower]);
return;
}
}
// Guardar último texto para que handleFreeText pueda pasarlo a Gemini
if (! in_array($step, ['await_email', 'await_otp', 'await_nombre', 'await_celular', 'await_amount', 'await_remitente_nombre'])) {
if (! in_array($step, $pasosDeTexto, true)) {
$state['data']['last_text'] = $text;
$this->setState($chatId, $state);
}
@@ -193,7 +219,7 @@ class TelegramBotService
$base64 = base64_encode($imageData['content']);
$mime = $imageData['mime'];
$datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $usuarioId, 'telegram');
$datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $usuarioId, $this->canal);
if (! $datosPago) {
$this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara.");
@@ -219,7 +245,7 @@ class TelegramBotService
}
$resultado = app(PagoValidadorService::class)->validar(
$datosPago, $usuarioId, $nombreUsuario, 'telegram'
$datosPago, $usuarioId, $nombreUsuario, $this->canal
);
$motivo = $resultado['motivo'] ?? '';
@@ -242,7 +268,7 @@ class TelegramBotService
);
// Lanzar job que reintenta cada 20s hasta 9 veces (≈3 min)
ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1)
ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1, $this->canal)
->delay(now()->addSeconds(20));
} else {
$motivoTexto = match ($motivo) {
@@ -283,7 +309,7 @@ class TelegramBotService
$this->send($chatId, "⏳ Verificando de nuevo...");
$nombreUsuario = User::find($usuarioId)?->name ?? '';
$resultado = app(PagoValidadorService::class)->validar($datosPago, $usuarioId, $nombreUsuario, 'telegram');
$resultado = app(PagoValidadorService::class)->validar($datosPago, $usuarioId, $nombreUsuario, $this->canal);
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
@@ -436,7 +462,7 @@ class TelegramBotService
AiUsageLog::registrar([
'servicio' => 'whisper',
'canal' => 'telegram',
'canal' => $this->canal,
'usuario_id' => $usuarioId,
'audio_segundos' => $duracionSegundos ?: null,
'tiempo_ms' => $tiempoMs,
@@ -461,7 +487,7 @@ class TelegramBotService
Log::warning('[TelegramBot] Whisper exception: ' . $e->getMessage());
AiUsageLog::registrar([
'servicio' => 'whisper',
'canal' => 'telegram',
'canal' => $this->canal,
'usuario_id' => $usuarioId,
'audio_segundos' => $duracionSegundos ?: null,
'tiempo_ms' => $tiempoMs,
@@ -510,12 +536,12 @@ class TelegramBotService
}
try {
$contact = ChatContact::where('canal', 'telegram')->where('canal_id', (string) $chatId)->first();
$contact = ChatContact::where('canal', $this->canal)->where('canal_id', (string) $chatId)->first();
if ($contact) {
$contact->update(['user_id' => $user->id, 'nombre' => $user->name]);
} else {
$contact = ChatContact::create([
'canal' => 'telegram',
'canal' => $this->canal,
'canal_id' => (string) $chatId,
'nombre' => $user->name,
'user_id' => $user->id,
@@ -632,12 +658,12 @@ class TelegramBotService
'rol_id' => $rolCliente?->id,
]);
$contact = ChatContact::where('canal', 'telegram')->where('canal_id', $chatId)->first();
$contact = ChatContact::where('canal', $this->canal)->where('canal_id', $chatId)->first();
if ($contact) {
$contact->update(['user_id' => $user->id, 'nombre' => $user->name]);
} else {
$contact = ChatContact::create([
'canal' => 'telegram',
'canal' => $this->canal,
'canal_id' => $chatId,
'nombre' => $user->name,
'user_id' => $user->id,
@@ -655,7 +681,7 @@ class TelegramBotService
if (! $conv) {
$conv = ChatConversation::create([
'contact_id' => $contact->id,
'canal' => 'telegram',
'canal' => $this->canal,
'estado' => 'bot',
'ultimo_mensaje_at' => now(),
]);
@@ -1339,7 +1365,7 @@ class TelegramBotService
if ($userId = ($this->getState($chatId)['user_id'] ?? null)) {
$state = $this->getState($chatId);
$nombre = $state['data']['nombre_remitente'] ?? null;
$solicitud = SolicitudRecarga::crear($userId, $monto, 'telegram', $nombre);
$solicitud = SolicitudRecarga::crear($userId, $monto, $this->canal, $nombre);
$state['data']['solicitud_recarga_id'] = $solicitud->id;
$this->setState($chatId, $state);
}
@@ -1522,7 +1548,7 @@ class TelegramBotService
if ($texto) {
$contexto = GeminiAgentService::buildContexto($state, $state['user_id'] ?? null);
$resultado = app(GeminiAgentService::class)->responder(
$texto, $contexto, $state['user_id'] ?? null, 'telegram'
$texto, $contexto, $state['user_id'] ?? null, $this->canal
);
if ($resultado['respuesta']) {
@@ -1573,20 +1599,30 @@ class TelegramBotService
// ─── State management ─────────────────────────────────────
private function stateKey(string $chatId): string
{
// Telegram conserva su prefijo histórico: cambiarlo cerraría todas las sesiones abiertas.
return $this->canal === 'telegram' ? "tgbot_state_{$chatId}" : "{$this->canal}bot_state_{$chatId}";
}
private function getState(string $chatId): array
{
return Cache::get("tgbot_state_{$chatId}", ['step' => 'await_email', 'data' => []]);
return Cache::get($this->stateKey($chatId), ['step' => 'await_email', 'data' => []]);
}
private function setState(string $chatId, array $state): void
{
Cache::put("tgbot_state_{$chatId}", $state, now()->addSeconds(self::STATE_TTL));
Cache::put($this->stateKey($chatId), $state, now()->addSeconds(self::STATE_TTL));
}
// ─── Telegram API ─────────────────────────────────────────
public function registrarComandos(): bool
{
if ($this->canal !== 'telegram') {
return false;
}
return $this->apiCall('setMyCommands', [
'commands' => [
['command' => 'start', 'description' => 'Iniciar / Menú principal'],
@@ -1608,6 +1644,10 @@ class TelegramBotService
]);
}
if ($this->canal === 'whatsapp') {
return $this->waSend($chatId, $text);
}
return $this->apiCall('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
@@ -1626,6 +1666,10 @@ class TelegramBotService
]);
}
if ($this->canal === 'whatsapp') {
return $this->waSend($chatId, $this->numerarTeclado($chatId, $text, $inlineKeyboard));
}
return $this->apiCall('sendMessage', [
'chat_id' => $chatId,
'text' => $text ?: '', // zero-width space fallback
@@ -1636,6 +1680,10 @@ class TelegramBotService
private function answerCallback(string $callbackQueryId): void
{
if ($this->canal !== 'telegram' || $callbackQueryId === '') {
return;
}
$this->apiCall('answerCallbackQuery', ['callback_query_id' => $callbackQueryId]);
}
@@ -1657,8 +1705,150 @@ class TelegramBotService
}
}
// ─── WhatsApp API ─────────────────────────────────────────
//
// WhatsApp no tiene teclados inline como Telegram: las mismas opciones se
// envían numeradas y la respuesta numérica del usuario se traduce de vuelta
// al callback_data equivalente, así los flujos son idénticos en ambos canales.
// ponytail: texto numerado en vez de mensajes interactivos; si se quieren
// botones nativos, el tope de Meta es 3 botones o 10 filas de lista.
private function kbKey(string $chatId): string
{
return "wabot_kb_{$chatId}";
}
private function numerarTeclado(string $chatId, string $text, array $inlineKeyboard): string
{
$mapa = [];
$lineas = [];
$n = 1;
foreach ($inlineKeyboard as $fila) {
foreach ($fila as $boton) {
$etiqueta = $boton['text'] ?? '';
$accion = $boton['callback_data'] ?? null;
if ($accion === null) {
continue;
}
$mapa[(string) $n] = $accion;
$lineas[] = "{$n}. {$etiqueta}";
$n++;
}
}
if (! $lineas) {
return $text;
}
Cache::put($this->kbKey($chatId), $mapa, now()->addHours(6));
$cuerpo = $text !== '' ? $text . "\n\n" : '';
return $cuerpo . implode("\n", $lineas) . "\n\n_Responde con el número de la opción._";
}
public function waMarkRead(string $messageId): bool
{
if ($this->canal !== 'whatsapp') {
return false;
}
return $this->waApiCall('messages', [
'messaging_product' => 'whatsapp',
'status' => 'read',
'message_id' => $messageId,
]);
}
private function waSend(string $chatId, string $text): bool
{
return $this->waApiCall('messages', [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $chatId,
'type' => 'text',
'text' => ['preview_url' => false, 'body' => $text],
]);
}
private function waApiCall(string $endpoint, array $payload): bool
{
[$token, $phoneId, $apiUrl] = $this->waCredenciales();
if (! $token || ! $phoneId) {
Log::warning('[WhatsAppBot] token o phone_number_id sin configurar.');
return false;
}
try {
$response = Http::withToken($token)
->timeout(10)
->post("{$apiUrl}{$phoneId}/{$endpoint}", $payload);
if (! $response->successful()) {
Log::error('[WhatsAppBot] API error: ' . $response->body());
}
return $response->successful();
} catch (\Throwable $e) {
Log::warning("[WhatsAppBot] waApiCall {$endpoint} failed: " . $e->getMessage());
return false;
}
}
/** @return array{0:string,1:string,2:string} token, phone_number_id, api_url */
private function waCredenciales(): array
{
return [
\App\Models\WhatsappSystemConfig::get('whatsapp_token'),
\App\Models\WhatsappSystemConfig::get('phone_number_id'),
\App\Models\WhatsappSystemConfig::get('whatsapp_api_url', 'https://graph.facebook.com/v22.0/'),
];
}
private function downloadWhatsappMedia(string $mediaId): ?array
{
[$token, , $apiUrl] = $this->waCredenciales();
if (! $token) {
return null;
}
try {
$info = Http::withToken($token)->timeout(10)->get("{$apiUrl}{$mediaId}");
$url = $info->json('url');
if (! $url) {
Log::warning('[WhatsAppBot] media sin URL: ' . $info->body());
return null;
}
$archivo = Http::withToken($token)->timeout(30)->get($url);
$content = $archivo->body();
if (! $content) {
return null;
}
return [
'content' => $content,
'mime' => $info->json('mime_type') ?: 'image/jpeg',
];
} catch (\Throwable $e) {
Log::warning('[WhatsAppBot] downloadWhatsappMedia failed: ' . $e->getMessage());
return null;
}
}
private function downloadTelegramFile(string $fileId): ?array
{
if ($this->canal === 'whatsapp') {
return $this->downloadWhatsappMedia($fileId);
}
$token = ChatConfig::get('telegram_token');
if (! $token) {
return null;
+1 -17
View File
@@ -265,25 +265,9 @@
</svg>
</button>
<div x-show="openWa" x-transition class="space-y-1">
<a href="{{ route('whatsapp.conversaciones') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.conversaciones') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg>
<span>Conversaciones</span>
</a>
<a href="{{ route('whatsapp.menus') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.menus') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12h14M5 6h14M5 18h14" /></svg>
<span>Menús del Bot</span>
</a>
<a href="{{ route('whatsapp.plantillas') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.plantillas') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z" /></svg>
<span>Plantillas</span>
</a>
<a href="{{ route('whatsapp.programados') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.programados') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
<span>Mensajes Programados</span>
</a>
<a href="{{ route('whatsapp.configuracion') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.configuracion') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
<span>Configuración Bot</span>
<span>Configuración API</span>
</a>
<a href="{{ route('whatsapp.logs') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.logs') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /></svg>
@@ -66,52 +66,12 @@
</label>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje de bienvenida *</label>
<textarea wire:model="welcome_message" rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('welcome_message') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Respuesta cuando no entiende *</label>
<textarea wire:model="default_no_match" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('default_no_match') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje al solicitar asesor *</label>
<textarea wire:model="advisor_message" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('advisor_message') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- Horarios --}}
<div class="bg-white rounded-xl shadow p-6 space-y-4">
<h2 class="text-base font-semibold text-gray-700 border-b border-gray-100 pb-2">Horarios de Atención</h2>
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700">Validar horario</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" wire:model="business_hours_enabled" value="1" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500"></div>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Hora inicio</label>
<input wire:model="business_hours_start" type="time"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Hora fin</label>
<input wire:model="business_hours_end" type="time"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
</div>
<p class="text-xs text-gray-400">
Los mensajes, menús y flujos del bot se administran en
<span class="font-medium text-gray-500">Chat / Bot Configuración</span>;
WhatsApp usa exactamente los mismos que Telegram y el chat web.
</p>
</div>
<div class="flex justify-end">
+31 -1
View File
@@ -19,4 +19,34 @@ assert($valida($firmar($payload, $secret), $payload, '') === false,
assert($valida('', $payload, $secret) === false, 'sin cabecera debe fallar');
assert($valida(hash_hmac('sha256', $payload, $secret), $payload, $secret) === false, 'sin prefijo sha256= debe fallar');
echo "OK: 6 casos de firma\n";
// ── Teclado numerado: mismo mapeo que TelegramBotService::numerarTeclado() ──
$numerar = function (array $teclado): array {
$mapa = []; $lineas = []; $n = 1;
foreach ($teclado as $fila) {
foreach ($fila as $boton) {
if (! isset($boton['callback_data'])) continue;
$mapa[(string) $n] = $boton['callback_data'];
$lineas[] = "{$n}. {$boton['text']}";
$n++;
}
}
return [$mapa, $lineas];
};
[$mapa, $lineas] = $numerar([
[['text' => 'Ver Servicios', 'callback_data' => 'svc_list'],
['text' => 'Promociones', 'callback_data' => 'promo_list']],
[['text' => 'Recargar', 'callback_data' => 'rec_init']],
]);
assert($lineas === ['1. Ver Servicios', '2. Promociones', '3. Recargar'], 'numeración corrida por filas');
assert($mapa['2'] === 'promo_list', 'el número devuelve el callback correcto');
assert($mapa['3'] === 'rec_init', 'la segunda fila sigue la numeración');
assert(count($mapa) === 3, 'un número por botón');
assert(! isset($mapa['4']), 'no inventa opciones');
[$vacio, $sinLineas] = $numerar([[['text' => 'sin accion']]]);
assert($vacio === [] && $sinLineas === [], 'botones sin callback_data se ignoran');
echo "OK: 6 casos de firma + 6 de teclado numerado\n";