Files
bot_palmas/services/NormalBot.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 b2abece097 fix: per-type commands extend globals instead of replacing them
Changed commands merge from ?? (replace) to array_merge (extend) so that
per-type overrides for menu/inicio/volver are applied without losing the
global salir, exit, informes, info commands.

Also updated DB config per_type:
- Type 1 (subir info): menu/inicio/volver → recibir_info → submenu_subir
- Type 2 (descargar): menu/inicio/volver → descargar_informes → submenu_informes
- Type 3 (admin): menu → show_main_menu (inherits global)

Each category now lands on its own menu from the first message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:25:15 -05:00

917 lines
40 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] ?? [];
// Per-type commands EXTEND globals (not replace), so salir/exit/etc. always work.
$commands = array_merge($config['commands'] ?? [], $perType['commands'] ?? []);
$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'];
$currentNode = $botCtx['current_node'];
$normalized = self::normalize($input);
// 1. Commands always win — escape from any state (salir, menu, etc.)
foreach ($commands as $keyword => $action) {
if ($normalized === self::normalize((string)$keyword)) {
ConversationContext::updateNode($ctxId, $action);
// Commands that match a flow
if (isset($flows[$action])) {
return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus);
}
// Commands that match a menu
if (isset($menus[$action])) {
return self::buildMenuResponse($menus[$action], $context['from'], $company);
}
return null;
}
}
// 2. Multi-step form: user is currently filling in fields
$meta = ConversationContext::getMetadata($ctxId);
if (!empty($meta['collecting'])) {
return self::handleCollectingInput($meta, $input, 'text', $context, $company, $ctxId, $flows, $menus);
}
// 2b. collect_for_each: collecting a value for each item in a list
if (!empty($meta['__foreach'])) {
return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows);
}
// 2c. collect_and_post: collecting sequential fields then POSTing
if (!empty($meta['__cap'])) {
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
}
// 3. Active node — resume conversation
$sentinels = ['collecting', '__greeted'];
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus);
}
// 4. Per-type greeting menu — only for brand-new sessions (currentNode === null)
$greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
// 5. Greeting text
$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;
}
// 6. Fallback flow
$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);
}
// 7. Greeting menu as fallback (texto no reconocido después de estar en __greeted)
if ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
// 8. Static fallback text
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
if ($fallback !== null) {
ConversationContext::updateNode($ctxId, null);
return self::sendText($fallback, $context['from'], $company);
}
return null;
}
// ── Multi-step collecting ────────────────────────────────────────────────
private static function handleCollectingInput(
array $meta, string $input, string $inputType,
array $context, array $company, int $ctxId, array $flows, array $menus
): ?array {
$col = $meta['collecting'];
$group = $col['meta_group'] ?? '';
$key = $col['meta_key'] ?? '';
$nextNode = $col['next_node'] ?? null;
$formData = $meta[$group] ?? [];
$formData[$key] = trim($input);
$meta[$group] = $formData;
unset($meta['collecting']);
ConversationContext::updateMetadata($ctxId, $meta);
if ($nextNode !== null) {
ConversationContext::updateNode($ctxId, $nextNode);
if (isset($flows[$nextNode])) {
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus);
}
} else {
ConversationContext::updateNode($ctxId, null);
}
return null;
}
private static function handleCollectInput(array $flow, array $context, array $company, int $ctxId): ?array
{
$meta = ConversationContext::getMetadata($ctxId);
$meta['collecting'] = [
'meta_group' => $flow['meta_group'] ?? '',
'meta_key' => $flow['meta_key'] ?? '',
'next_node' => $flow['next_node'] ?? null,
];
ConversationContext::updateMetadata($ctxId, $meta);
ConversationContext::updateNode($ctxId, 'collecting');
return self::sendText($flow['prompt'] ?? '¿Cuál es el valor?', $context['from'], $company);
}
// ── Dynamic list (from endpoint) ─────────────────────────────────────────
private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array
{
$meta = ConversationContext::getMetadata($ctxId);
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
if ($items === null || count($items) === 0) {
ConversationContext::reset($ctxId);
return self::sendText(
'No se pudieron cargar las opciones ahora. Escribe *menu* para volver.',
$context['from'], $company
);
}
$meta['collecting'] = [
'meta_group' => $flow['meta_group'] ?? '',
'meta_key' => $flow['meta_key'] ?? '',
'next_node' => $flow['next_node'] ?? null,
];
ConversationContext::updateMetadata($ctxId, $meta);
ConversationContext::updateNode($ctxId, 'collecting');
return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
}
// Reemplaza {variable} en la URL con valores del metadata acumulado
private static function substituteUrlVars(string $url, array $meta): string
{
foreach ($meta as $group => $fields) {
if (!is_array($fields)) continue;
foreach ($fields as $key => $value) {
$url = str_replace('{' . $key . '}', urlencode((string)$value), $url);
}
}
return $url;
}
private static function fetchDynamicList(string $endpointKey, array $company, array $meta = []): ?array
{
if ($endpointKey === '') return null;
$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'])) return null;
$apiKey = $company['api_key'] ?? '';
$url = self::substituteUrlVars($ep['url'], $meta);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey,
],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$body) return null;
$data = json_decode($body, true);
if (!is_array($data)) return null;
// Accept both top-level array and {data: [...]}
return isset($data['data']) && is_array($data['data']) ? $data['data'] : $data;
}
private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array
{
$valueField = $flow['value_field'] ?? 'id';
$labelField = $flow['label_field'] ?? 'name';
$rows = [];
foreach (array_slice($items, 0, 10) as $item) {
$id = (string)($item[$valueField] ?? '');
$title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
if ($id === '') continue;
$rows[] = ['id' => $id, 'title' => $title];
}
if (empty($rows)) return null;
$interactive = [
'type' => 'list',
'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? 'Selecciona', 0, 60)],
'body' => ['text' => mb_substr($flow['body'] ?? 'Elige una opción:', 0, 1024)],
'footer' => ['text' => mb_substr($company['display_name'] ?? '', 0, 60)],
'action' => [
'button' => mb_substr($flow['button'] ?? 'Ver opciones', 0, 20),
'sections' => [[
'title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24),
'rows' => $rows,
]],
],
];
return self::enqueueInteractive($to, $interactive, $company);
}
// ── collect_for_each — pide un valor por cada ítem de una lista dinámica ──
private static function handleCollectForEach(array $flow, array $context, array $company, int $ctxId): ?array
{
$meta = ConversationContext::getMetadata($ctxId);
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
if (empty($items)) {
ConversationContext::reset($ctxId);
return self::sendText(
'⚠️ No se pudo cargar el listado. Escribe *menu* para volver.',
$context['from'], $company
);
}
$meta['__foreach'] = [
'items' => $items,
'index' => 0,
'collected' => [],
'value_field' => $flow['value_field'] ?? 'id',
'label_field' => $flow['label_field'] ?? 'name',
'question' => $flow['question'] ?? '¿Cuál es el valor?',
'header' => $flow['header'] ?? '📋 Ingreso de datos',
'endpoint_key'=> $flow['endpoint_key'] ?? '',
'success_text'=> $flow['success_text'] ?? '✅ Datos registrados correctamente.',
];
ConversationContext::updateMetadata($ctxId, $meta);
return self::foreachAskNext($meta['__foreach'], $context['from'], $company);
}
private static function handleForeachInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows): ?array
{
$fe = $meta['__foreach'];
$items = $fe['items'];
$idx = (int)$fe['index'];
// Validate: must be a number
if (!is_numeric(str_replace(',', '.', $input))) {
$item = $items[$idx];
$label = $item[$fe['label_field']] ?? "Item " . ($idx + 1);
return self::sendText(
"⚠️ Ingresa solo un número.\n\n*{$label}*\n{$fe['question']}",
$context['from'], $company
);
}
$item = $items[$idx];
$key = (string)($item[$fe['value_field']] ?? $idx);
$label = (string)($item[$fe['label_field']] ?? $key);
$value = str_replace(',', '.', trim($input));
$fe['collected'][$key] = ['label' => $label, 'valor' => $value];
$fe['index'] = $idx + 1;
$meta['__foreach'] = $fe;
ConversationContext::updateMetadata($ctxId, $meta);
// More items to collect?
if ($fe['index'] < count($items)) {
return self::foreachAskNext($fe, $context['from'], $company);
}
// All collected — show summary and confirm/cancel buttons
$summary = "*📊 Resumen — {$fe['header']}*\n\n";
foreach ($fe['collected'] as $row) {
$summary .= "• {$row['label']}: *{$row['valor']}*\n";
}
$summary .= "\n¿Confirmas el registro?";
return self::enqueueInteractive($context['from'], [
'type' => 'button',
'body' => ['text' => $summary],
'action' => ['buttons' => [
['type' => 'reply', 'reply' => ['id' => '__foreach_confirm', 'title' => '✅ Confirmar']],
['type' => 'reply', 'reply' => ['id' => '__foreach_cancel', 'title' => '❌ Cancelar']],
]],
], $company);
}
private static function foreachAskNext(array $fe, string $to, array $company): ?array
{
$idx = (int)$fe['index'];
$item = $fe['items'][$idx];
$label = $item[$fe['label_field']] ?? "Item " . ($idx + 1);
$total = count($fe['items']);
$text = "*{$fe['header']}* ({$idx}/{$total})\n\n"
. "📍 *{$label}*\n"
. $fe['question'];
return self::sendText($text, $to, $company);
}
private static function submitForeach(array $meta, array $context, array $company, int $ctxId): ?array
{
$fe = $meta['__foreach'];
$endpointKey = $fe['endpoint_key'] ?? '';
$nav = "\n\nEscribe *menu* para ver más opciones.";
// Build POST body: array of {id, valor} objects
$body = [];
foreach ($fe['collected'] as $id => $row) {
$body[] = ['id' => $id, 'label' => $row['label'], 'valor' => $row['valor']];
}
unset($meta['__foreach']);
ConversationContext::updateMetadata($ctxId, $meta);
ConversationContext::reset($ctxId);
if ($endpointKey === '') {
return self::sendText('⚠️ Endpoint de envío no configurado.' . $nav, $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'])) {
return self::sendText('⚠️ Endpoint no configurado. Contacta al administrador.' . $nav, $context['from'], $company);
}
$apiKey = $company['api_key'] ?? '';
$ch = curl_init($ep['url']);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr || $httpCode >= 400) {
self::log("collect_for_each POST error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
}
return self::sendText($fe['success_text'] . $nav, $context['from'], $company);
}
// ── collect_and_post — pide campos definidos en config y hace POST ─────────
private static function handleCollectAndPost(array $flow, array $context, array $company, int $ctxId): ?array
{
$fields = $flow['fields'] ?? [];
// If no inline fields, auto-detect from endpoint's body_fields definition
if (empty($fields)) {
$epKey = $flow['endpoint_key'] ?? '';
if ($epKey !== '') {
$stmt = db()->prepare("SELECT body_fields FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
$stmt->execute([(int)$company['id'], $epKey]);
$row = $stmt->fetch();
if ($row && !empty($row['body_fields'])) {
$fields = json_decode($row['body_fields'], true) ?: [];
}
}
}
if (empty($fields)) {
ConversationContext::reset($ctxId);
return self::sendText('⚠️ Flujo sin campos configurados. Define los campos en el endpoint. Escribe *menu* para volver.', $context['from'], $company);
}
$meta = ConversationContext::getMetadata($ctxId);
$meta['__cap'] = [
'fields' => $fields,
'index' => 0,
'collected' => [],
'endpoint_key' => $flow['endpoint_key'] ?? '',
'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.',
'confirm' => (bool)($flow['confirm'] ?? true),
'header' => $flow['header'] ?? '📋 Registro',
];
ConversationContext::updateMetadata($ctxId, $meta);
return self::capAskNext($meta['__cap'], $context['from'], $company);
}
private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array
{
$cap = $meta['__cap'];
$fields = $cap['fields'];
$idx = (int)$cap['index'];
$field = $fields[$idx];
// Save answer
$cap['collected'][$field['key']] = trim($input);
$cap['index'] = $idx + 1;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
// More fields?
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company);
}
// All done — confirm or submit directly
if ($cap['confirm']) {
$summary = "*{$cap['header']}*\n\n";
foreach ($cap['collected'] as $k => $v) {
$label = self::capFieldLabel($fields, $k);
$summary .= "• {$label}: *{$v}*\n";
}
$summary .= "\n¿Confirmas el registro?";
return self::enqueueInteractive($context['from'], [
'type' => 'button',
'body' => ['text' => $summary],
'action' => ['buttons' => [
['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']],
['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']],
]],
], $company);
}
return self::submitCapPost($meta, $context, $company, $ctxId);
}
private static function capAskNext(array $cap, string $to, array $company): ?array
{
$idx = (int)$cap['index'];
$total = count($cap['fields']);
$field = $cap['fields'][$idx];
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
return self::sendText($text, $to, $company);
}
private static function capFieldLabel(array $fields, string $key): string
{
foreach ($fields as $f) {
if (($f['key'] ?? '') === $key) return $f['label'] ?? $f['key'];
}
return $key;
}
private static function submitCapPost(array $meta, array $context, array $company, int $ctxId): ?array
{
$cap = $meta['__cap'];
$endpointKey = $cap['endpoint_key'] ?? '';
$nav = "\n\nEscribe *menu* para ver más opciones.";
unset($meta['__cap']);
ConversationContext::updateMetadata($ctxId, $meta);
ConversationContext::reset($ctxId);
if ($endpointKey === '') {
return self::sendText('⚠️ Endpoint no configurado.' . $nav, $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'])) {
return self::sendText('⚠️ Endpoint no encontrado. Contacta al administrador.' . $nav, $context['from'], $company);
}
$apiKey = $company['api_key'] ?? '';
$payload = array_merge($cap['collected'], [
'telefono' => $context['from'],
'nombre' => $context['name'] ?? '',
]);
$ch = curl_init($ep['url']);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
'X-API-Key: ' . $apiKey,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr || $httpCode >= 400) {
self::log("collect_and_post error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
}
return self::sendText($cap['success_text'] . $nav, $context['from'], $company);
}
// ── Submit form (end of multi-step collection) ───────────────────────────
private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
{
$meta = ConversationContext::getMetadata($ctxId);
$group = $flow['meta_group'] ?? '';
$formData = $meta[$group] ?? [];
unset($meta[$group], $meta['collecting']);
ConversationContext::updateMetadata($ctxId, $meta);
$params = [
'endpoint_key' => $flow['endpoint_key'] ?? '',
'caption' => $flow['caption'] ?? 'Informe generado.',
'filename' => $flow['filename'] ?? 'reporte.pdf',
'query' => $formData, // form fields become URL query params
];
return self::executeApiReport($params, $context, $company, $ctxId);
}
// ── Flow dispatcher ──────────────────────────────────────────────────────
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),
'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId),
'collect_for_each' => self::handleCollectForEach($flow, $context, $company, $ctxId),
'collect_and_post' => self::handleCollectAndPost($flow, $context, $company, $ctxId),
default => null,
};
}
// ── Functions ────────────────────────────────────────────────────────────
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);
})(),
'goodbye' => (function () use ($ctxId, $context, $company) {
ConversationContext::reset($ctxId);
return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $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'] ?? '';
$nav = "\n\nEscribe *menu* para ver más opciones o *salir* para terminar.";
if ($endpointKey === '') {
ConversationContext::reset($ctxId);
return self::sendText('Error: endpoint no configurado.' . $nav, $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.' . $nav, $context['from'], $company);
}
$meta = ConversationContext::getMetadata($ctxId);
$url = self::substituteUrlVars($ep['url'], $meta); // reemplaza {var} con valores del formulario
$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.' . $nav, $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.' . $nav, $context['from'], $company);
}
$mimeMap = [
'application/pdf' => ['pdf', 'application/pdf'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
'application/vnd.ms-excel' => ['xls', 'application/vnd.ms-excel'],
'text/csv' => ['csv', 'text/csv'],
'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'application/vnd.oasis.opendocument.spreadsheet'],
];
$ext = 'pdf';
$mime = 'application/pdf';
foreach ($mimeMap as $m => $info) {
if (str_starts_with($contentType ?? '', $m)) {
[$ext, $mime] = $info;
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.' . $nav, $context['from'], $company);
}
$reportName = $params['filename'] ?? ('reporte.' . $ext);
$caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName);
ConversationContext::reset($ctxId);
return self::sendText('✅ Reporte enviado.' . $nav, $context['from'], $company);
}
// ── Public helpers ───────────────────────────────────────────────────────
public static function buildGreetingMenu(array $menu, string $to, array $company): ?array
{
return self::buildMenuResponse($menu, $to, $company);
}
// ── Interactive messages ─────────────────────────────────────────────────
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'];
// Multi-step form: interactive selection is the collected value
$meta = ConversationContext::getMetadata($ctxId);
if (!empty($meta['collecting'])) {
return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
}
// collect_and_post confirmation (confirm/cancel button)
if (!empty($meta['__cap'])) {
if ($input === '__cap_confirm') {
return self::submitCapPost($meta, $context, $company, $ctxId);
}
if ($input === '__cap_cancel') {
$m = ConversationContext::getMetadata($ctxId);
unset($m['__cap']);
ConversationContext::updateMetadata($ctxId, $m);
ConversationContext::reset($ctxId);
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
}
}
// collect_for_each confirmation (confirm/cancel button)
if (!empty($meta['__foreach'])) {
if ($input === '__foreach_confirm') {
return self::submitForeach($meta, $context, $company, $ctxId);
}
if ($input === '__foreach_cancel') {
$meta2 = ConversationContext::getMetadata($ctxId);
unset($meta2['__foreach']);
ConversationContext::updateMetadata($ctxId, $meta2);
ConversationContext::reset($ctxId);
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
}
}
// Standard button/row resolution against static menus
foreach ($flows as $flowId => $flow) {
if (($flow['type'] ?? '') !== 'menu') continue;
$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;
}
}
}
// Direct flow match (button id == flow key)
if (isset($flows[$input])) {
ConversationContext::updateNode($ctxId, $input);
return self::handleFlow($flows[$input], $context, $company, $ctxId, $menus);
}
return null;
}
// ── WhatsApp senders ─────────────────────────────────────────────────────
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 (array_slice($menu['buttons'] ?? [], 0, 3) 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;
}
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'] ?? '';
$config = $json !== '' ? json_decode($json, true) : null;
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);
}
private static function log(string $msg): void
{
if (class_exists('WpWebhook')) WpWebhook::log('NORMALBOT', $msg);
}
}