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>
382 lines
15 KiB
PHP
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;
|
|
}
|
|
}
|