Files
bot_palmas/services/NormalBot.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 dee459d53f fix: reset context on api_report failure so user is never stuck
On any api_report failure (missing endpoint, curl error, HTTP error,
upload failure), reset the bot_context so the next message goes through
the normal flow instead of retrying the failed report indefinitely.
Also reduce curl timeout from 60s to 15s to avoid WhatsApp webhook
timeouts that cause Meta to retry the same message repeatedly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 11:40:23 -05:00

382 lines
15 KiB
PHP

<?php
declare(strict_types=1);
class NormalBot
{
public static function process(array $company, array $context, string $input): ?array
{
$config = self::getConfig($company);
$permType = (string)($company['_permission_type'] ?? 1);
$perType = $config['per_type'][$permType] ?? [];
$commands = $perType['commands'] ?? $config['commands'] ?? [];
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
$ctxId = null;
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
$ctxId = (int)$botCtx['id'];
$currentNode = $botCtx['current_node'];
$normalized = self::normalize($input);
if ($currentNode !== null && isset($flows[$currentNode])) {
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus);
}
$matchedCommand = null;
foreach ($commands as $keyword => $action) {
if ($normalized === self::normalize($keyword)) {
$matchedCommand = $action;
break;
}
}
if ($matchedCommand !== null) {
ConversationContext::updateNode($ctxId, $matchedCommand);
if (isset($flows[$matchedCommand])) {
return self::handleFlow($flows[$matchedCommand], $context, $company, $ctxId, $menus);
}
if (isset($menus[$matchedCommand])) {
return self::buildMenuResponse($menus[$matchedCommand], $context['from'], $company);
}
return null;
}
$greeting = $perType['greeting'] ?? $config['greeting'] ?? null;
if ($greeting !== null && $currentNode === null) {
$greetingFlowId = $perType['greeting_flow'] ?? 'greeting';
if (isset($flows[$greetingFlowId])) {
ConversationContext::updateNode($ctxId, $greetingFlowId);
return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus);
}
$response = self::sendText($greeting, $context['from'], $company);
ConversationContext::updateNode($ctxId, null);
return $response;
}
$fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null;
if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) {
ConversationContext::updateNode($ctxId, $fallbackFlowId);
return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus);
}
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
if ($fallback !== null) {
ConversationContext::updateNode($ctxId, null);
return self::sendText($fallback, $context['from'], $company);
}
return null;
}
private static function resolveMenu($menuRef, array $company, array $allMenus = []): array
{
if (is_string($menuRef)) {
if (isset($allMenus[$menuRef])) {
return $allMenus[$menuRef];
}
$config = self::getConfig($company);
return ($config['menus'] ?? [])[$menuRef] ?? [];
}
return is_array($menuRef) ? $menuRef : [];
}
private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = []): ?array
{
$type = $flow['type'] ?? 'text';
return match ($type) {
'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company),
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
default => null,
};
}
private static function executeFunction(string $function, array $params, array $context, array $company, int $ctxId): ?array
{
return match ($function) {
'reset' => (function () use ($ctxId, $context, $company) {
ConversationContext::reset($ctxId);
return self::sendText('¿En qué más puedo ayudarte?', $context['from'], $company);
})(),
'forward_to_ai' => null,
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
default => null,
};
}
private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array
{
$endpointKey = $params['endpoint_key'] ?? '';
if ($endpointKey === '') {
return self::sendText('Error: endpoint no configurado.', $context['from'], $company);
}
$stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1");
$stmt->execute([(int)$company['id'], $endpointKey]);
$ep = $stmt->fetch();
if (!$ep || empty($ep['url'])) {
self::log("API report: endpoint_key '{$endpointKey}' no configurado para company {$company['id']}");
ConversationContext::reset($ctxId);
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.', $context['from'], $company);
}
$url = $ep['url'];
$method = strtoupper($ep['method'] ?? 'GET');
$apiKey = $company['api_key'] ?? '';
$extraQuery = $params['query'] ?? [];
$dateMode = $params['date_mode'] ?? '';
if ($dateMode === 'today') {
$extraQuery['fecha'] = date('Y-m-d');
} elseif ($dateMode === 'last_30') {
$extraQuery['fecha_inicio'] = date('Y-m-d', strtotime('-30 days'));
$extraQuery['fecha_fin'] = date('Y-m-d');
}
$extraQuery['telefono'] = $context['from'];
$extraQuery['nombre'] = $context['name'] ?? '';
$glue = str_contains($url, '?') ? '&' : '?';
$url .= $glue . http_build_query($extraQuery);
$ch = curl_init($url);
$curlOpts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey,
'User-Agent: bot-palmas360/1.0',
],
];
if ($method === 'POST') {
$curlOpts[CURLOPT_POST] = true;
$curlOpts[CURLOPT_POSTFIELDS] = '{}';
}
curl_setopt_array($ch, $curlOpts);
$content = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
self::log("API report error [{$endpointKey}]: {$error}");
ConversationContext::reset($ctxId);
return self::sendText('Error al obtener el reporte. Intenta de nuevo.', $context['from'], $company);
}
if ($httpCode >= 400) {
self::log("API report HTTP {$httpCode} [{$endpointKey}]: " . mb_substr($content, 0, 200));
ConversationContext::reset($ctxId);
return self::sendText('Error al obtener el reporte. Intenta de nuevo.', $context['from'], $company);
}
$mimeMap = [
'application/pdf' => ['pdf', 'pdf'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'xlsx'],
'application/vnd.ms-excel' => ['xls', 'xls'],
'text/csv' => ['csv', 'csv'],
'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'ods'],
];
$ext = 'pdf';
$mime = 'application/pdf';
foreach ($mimeMap as $m => $info) {
if (str_starts_with($contentType ?? '', $m)) {
$ext = $info[0];
$mime = $info[1] ?? $m;
break;
}
}
$phoneNumberId = $company['phone_number_id'] ?? env('WHATSAPP_PHONE_NUMBER_ID', '');
if ($phoneNumberId === '') {
return self::sendText('Error: canal de envío no configurado.', $context['from'], $company);
}
$tmpFile = sys_get_temp_dir() . '/report_' . bin2hex(random_bytes(8)) . '.' . $ext;
file_put_contents($tmpFile, $content);
$upload = WhatsAppSender::uploadMedia($tmpFile, $mime, $phoneNumberId);
if (!$upload['success'] || !$upload['media_id']) {
self::log("API report upload failed [{$endpointKey}]: " . ($upload['error'] ?? 'unknown'));
ConversationContext::reset($ctxId);
return self::sendText('Error al enviar el reporte. Intenta de nuevo.', $context['from'], $company);
}
$reportName = $params['filename'] ?? ('reporte.' . $ext);
$caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
$result = WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName);
ConversationContext::reset($ctxId);
return null;
}
private static function buildMenuResponse(array $menu, string $to, array $company): ?array
{
$menuType = $menu['type'] ?? 'list';
if ($menuType === 'list') {
$interactive = [
'type' => 'list',
'header' => [
'type' => 'text',
'text' => mb_substr($menu['header'] ?? 'Menú', 0, 60),
],
'body' => [
'text' => mb_substr($menu['body'] ?? 'Selecciona una opción:', 0, 1024),
],
'footer' => [
'text' => mb_substr($menu['footer'] ?? $company['display_name'] ?? '', 0, 60),
],
'action' => [
'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20),
'sections' => [],
],
];
foreach ($menu['sections'] ?? [] as $section) {
$rows = [];
foreach ($section['rows'] ?? [] as $row) {
$rows[] = [
'id' => mb_substr($row['id'] ?? '', 0, 200),
'title' => mb_substr($row['title'] ?? '', 0, 24),
'description' => isset($row['description']) ? mb_substr($row['description'], 0, 72) : null,
];
}
$interactive['action']['sections'][] = [
'title' => mb_substr($section['title'] ?? '', 0, 24),
'rows' => $rows,
];
}
return self::enqueueInteractive($to, $interactive, $company);
}
if ($menuType === 'button') {
$buttons = [];
foreach ($menu['buttons'] ?? [] as $btn) {
$buttons[] = [
'type' => 'reply',
'reply' => [
'id' => mb_substr($btn['id'] ?? '', 0, 256),
'title' => mb_substr($btn['title'] ?? '', 0, 20),
],
];
}
$interactive = [
'type' => 'button',
'body' => [
'text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024),
],
'action' => ['buttons' => $buttons],
];
return self::enqueueInteractive($to, $interactive, $company);
}
return null; // tipo de menú desconocido
}
private static function sendText(string $text, string $to, array $company): array
{
return [
'action' => 'send',
'type' => 'text',
'to' => $to,
'payload' => json_encode(['text' => $text]),
];
}
private static function sendImage(string $mediaId, ?string $caption, string $to, array $company): array
{
return [
'action' => 'send',
'type' => 'image',
'to' => $to,
'payload' => json_encode(['media_id' => $mediaId, 'caption' => $caption]),
];
}
private static function enqueueInteractive(string $to, array $interactive, array $company): array
{
return [
'action' => 'send',
'type' => 'interactive',
'to' => $to,
'payload' => json_encode(['interactive' => $interactive]),
];
}
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 normalize(string $input): string
{
$input = mb_strtolower(trim($input));
$input = str_replace(['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'], ['a', 'e', 'i', 'o', 'u', 'u', 'n'], $input);
return preg_replace('/[^a-z0-9\s]/', '', $input);
}
public static function processInteractive(array $company, array $context, string $input): ?array
{
$config = self::getConfig($company);
$permType = (string)($company['_permission_type'] ?? 1);
$perType = $config['per_type'][$permType] ?? [];
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
$ctxId = (int)$botCtx['id'];
foreach ($flows as $flowId => $flow) {
if (($flow['type'] ?? '') === 'menu') {
$menu = self::resolveMenu($flow['menu'] ?? [], $company, $menus);
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
if (($row['id'] ?? '') === $input) {
ConversationContext::updateNode($ctxId, $row['id']);
if (isset($flows[$row['id']])) {
return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus);
}
return null;
}
}
}
foreach ($menu['buttons'] ?? [] as $btn) {
if (($btn['id'] ?? '') === $input) {
ConversationContext::updateNode($ctxId, $btn['id']);
if (isset($flows[$btn['id']])) {
return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus);
}
return null;
}
}
}
}
return null;
}
}