Files
bot_palmas/services/BotRouter.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 497743ceb1 feat: NLU fallback — route unrecognized text to AI in hybrid+NLU mode
NormalBot.process() gains suppressFallback param. When true, steps 7-8
(greeting menu fallback, static fallback text) return null instead of
responding, letting BotRouter hand off to NLU/AI.

BotRouter.runHybridBot() passes suppressFallback=$nluEnabled so that:
- NLU on: unrecognized text → NLU → route to flow or AI chat
- NLU off: existing fallback behavior unchanged
- Commands, active flows, first-session greeting: always unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 09:09:49 -05:00

309 lines
12 KiB
PHP

<?php
declare(strict_types=1);
class BotRouter
{
public static function route(array $company, array $context, string $input, string $inputType = 'text'): void
{
if (self::shouldSkipBot($company, $context)) {
return;
}
$response = self::classifyAndProcess($company, $context, $input, $inputType);
if ($response === null) {
self::log("Bot no generó respuesta para {$context['from']}");
return;
}
if ((int)($company['requires_approval'] ?? 0) === 1) {
self::saveForApproval($company, $context, $input, $response);
return;
}
$metaPhoneNumberId = $context['phone_number_id'] ?? '';
self::enqueueResponse($response, $company, $metaPhoneNumberId);
}
private static function saveForApproval(array $company, array $context, string $input, array $response): void
{
$botType = $company['bot_type'] ?? 'normal';
$result = PendingApproval::create(
companyId: (int)$company['id'],
phone: $context['from'],
name: $context['name'] ?? null,
incomingMsg: $input,
botResponse: $response,
botType: $botType,
context: $context,
);
self::log("Respuesta guardada para aprobación #{$result['id']} — empresa {$company['name']}");
self::notifyErp($company, $result['id']);
}
private static function notifyErp(array $company, int $pendingId): void
{
$config = self::getConfig($company);
$webhookUrl = $config['approval_webhook'] ?? '';
if ($webhookUrl === '') {
return;
}
$payload = json_encode([
'action' => 'pending',
'pending_id' => $pendingId,
'company_id' => (int)$company['id'],
'message' => 'Nuevo mensaje pendiente de aprobación',
]);
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . ($company['api_key'] ?? ''),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
curl_exec($ch);
curl_close($ch);
}
private static function classifyAndProcess(array $company, array $context, string $input, string $inputType): ?array
{
$botType = $company['bot_type'] ?? 'normal';
return match ($botType) {
'normal' => self::runNormalBot($company, $context, $input, $inputType),
'ai' => self::runAiBot($company, $context, $input),
'hybrid' => self::runHybridBot($company, $context, $input, $inputType),
default => null,
};
}
private static function runNormalBot(array $company, array $context, string $input, string $inputType, bool $suppressFallback = false): ?array
{
if ($inputType === 'interactive' || $inputType === 'button') {
return NormalBot::processInteractive($company, $context, $input);
}
return NormalBot::process($company, $context, $input, $suppressFallback);
}
private static function runAiBot(array $company, array $context, string $input): ?array
{
return AiBot::process($company, $context, $input);
}
private static function isMediaType(string $inputType): bool
{
return in_array($inputType, ['image', 'audio', 'video', 'document', 'sticker'], true);
}
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
{
$config = self::getConfig($company);
$nluEnabled = (bool)($config['nlu'] ?? false);
$aiEnabled = (bool)($config['ai_for_media'] ?? true);
// ── Media (audio/imagen) ──────────────────────────────────────────────
if (self::isMediaType($inputType)) {
if (!$aiEnabled) {
return self::categoryMenuFallback($company, $context, $config);
}
if ($nluEnabled && in_array($inputType, ['audio', 'image'], true)) {
$mediaId = $context['media_id'] ?? $input; // injected by WpWebhook::handleMedia
$text = $inputType === 'audio'
? MediaTranscriber::transcribeAudio($mediaId, $company)
: MediaTranscriber::extractFromImage($mediaId, $company);
if ($text !== null && trim($text) !== '') {
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
// Confirmar al usuario lo que entendió
$prefix = $inputType === 'audio' ? '🎤 Transcripción' : '🖼️ Imagen analizada';
WhatsAppSender::sendText(
$context['from'],
"{$prefix}: _{$text}_",
$context['phone_number_id'] ?? ''
);
return self::runNluOnText($company, $context, $text, $config);
}
}
return AiBot::processMedia($company, $context, $inputType, $input);
}
// ── Texto / interactivo: NormalBot primero ────────────────────────────
// suppressFallback=true cuando NLU activo: si NormalBot no reconoce, retorna null
// y dejamos que NLU decida en vez de mostrar el menú de bienvenida como fallback
$response = self::runNormalBot($company, $context, $input, $inputType, $nluEnabled);
if ($response !== null) {
return $response;
}
// NormalBot no reconoció → NLU si está activo, si no menú categoría
if ($nluEnabled && $inputType === 'text') {
return self::runNluOnText($company, $context, $input, $config);
}
return self::categoryMenuFallback($company, $context, $config);
}
private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
{
$result = AiBot::routeOrChat($company, $context, $input);
if ($result['action'] === 'route') {
$key = $result['key'];
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
ConversationContext::updateNode((int)$botCtx['id'], $key);
self::log("NLU: {$context['from']} → flow [{$key}]");
// Ejecutar con input vacío — NormalBot retoma current_node
return NormalBot::process($company, $context, '');
}
$text = trim($result['text'] ?? '');
if ($text !== '') {
self::log("NLU: {$context['from']} → chat libre");
return [
'action' => 'send',
'type' => 'text',
'to' => $context['from'],
'payload' => json_encode(['text' => $text]),
];
}
return self::categoryMenuFallback($company, $context, $config);
}
private static function categoryMenuFallback(array $company, array $context, array $config): ?array
{
$permType = (string)($context['permission_type'] ?? 1);
$perType = $config['per_type'][$permType] ?? [];
$greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($greetingMenuKey !== null) {
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
if (isset($menus[$greetingMenuKey])) {
return NormalBot::buildGreetingMenu($menus[$greetingMenuKey], $context['from'], $company);
}
}
return null;
}
private static function shouldSkipBot(array $company, array $context): bool
{
if (!(int)($company['is_active'] ?? 0)) {
return true;
}
$config = self::getConfig($company);
$ignorePrefixes = $config['ignore_prefixes'] ?? [];
foreach ($ignorePrefixes as $prefix) {
if (str_starts_with($context['from'] ?? '', $prefix)) {
return true;
}
}
return false;
}
private static function enqueueResponse(array $response, array $company, string $metaPhoneNumberId = ''): void
{
$to = $response['to'] ?? '';
$type = $response['type'] ?? 'text';
$payload = $response['payload'] ?? '';
if ($to === '' || $payload === '') {
return;
}
try {
$stmt = db()->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
$stmt->execute([$company['id'], $to, $type, $payload]);
$queueId = (int)db()->lastInsertId();
self::log("Bot encoló respuesta {$type} para {$to} [q#{$queueId}]");
$content = $payload;
if ($type !== 'text') {
$decoded = json_decode($payload, true);
$content = $decoded['caption'] ?? $decoded['body'] ?? $decoded['text'] ?? $payload;
}
$messageId = 'bot_out_' . $company['id'] . '_' . time() . '_' . bin2hex(random_bytes(4));
$stmt2 = db()->prepare("
INSERT IGNORE INTO conversations
(company_id, message_id, phone_number, direction, message_type, content, timestamp)
VALUES (?, ?, ?, 'outbound', ?, ?, ?)
");
$stmt2->execute([
$company['id'],
$messageId,
$to,
$type,
mb_substr($content, 0, 1000),
time(),
]);
$phoneNumberId = $metaPhoneNumberId ?: ($company['phone_number_id'] ?: env('WHATSAPP_PHONE_NUMBER_ID', ''));
if ($phoneNumberId === '') {
self::log("ERROR: phone_number_id no configurado para company {$company['id']}");
return;
}
$decodedPayload = json_decode($payload, true);
if (!is_array($decodedPayload)) {
self::log("ERROR: payload inválido para q#{$queueId}");
return;
}
$sendResult = match ($type) {
'text' => WhatsAppSender::sendText($to, $decodedPayload['text'] ?? '', $phoneNumberId),
'interactive' => WhatsAppSender::sendInteractive($to, $decodedPayload['interactive'] ?? [], $phoneNumberId),
'image' => WhatsAppSender::sendImage($to, $decodedPayload['media_id'] ?? $decodedPayload['url'] ?? '', $phoneNumberId, $decodedPayload['caption'] ?? null),
default => ['success' => false, 'error' => "Tipo no soportado: {$type}"],
};
if ($sendResult['success']) {
db()->prepare("UPDATE outbound_queue SET status = 'sent', wam_id = ?, updated_at = NOW() WHERE id = ?")
->execute([$sendResult['wam_id'], $queueId]);
self::log("Bot envió {$type} a {$to} (wam:{$sendResult['wam_id']})");
} else {
db()->prepare("UPDATE outbound_queue SET status = 'queued', last_error = ?, updated_at = NOW() WHERE id = ?")
->execute([$sendResult['error'] ?? 'error', $queueId]);
self::log("Bot no pudo enviar {$type} a {$to}: {$sendResult['error']} — queda en cola");
}
} catch (\Throwable $e) {
self::log('ERROR en enqueueResponse: ' . $e->getMessage());
}
}
private static function getConfig(array $company): array
{
$json = $company['config_json'] ?? '';
if ($json === '') return [];
$config = json_decode($json, true);
return is_array($config) ? $config : [];
}
private static function log(string $message): void
{
$dir = dirname(__DIR__) . '/storage/logs';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$file = $dir . '/bot-' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . '] [BOT] ' . $message . PHP_EOL;
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
if (env('APP_ENV', 'production') === 'local') {
error_log($line);
}
}
}