Files
bot_palmas/services/NormalBot.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 09342fa786 feat: pass collected values to fetchDynamicList for dependent selects
Enables select fields to filter their list based on previously collected
values via URL variable substitution (e.g. lotes filtered by finca_id).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 20:57:18 -05:00

1327 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
class NormalBot
{
public static function process(array $company, array $context, string $input, bool $suppressFallback = false): ?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)) {
// Clear any in-progress multi-step state so the command is a clean reset
$m = ConversationContext::getMetadata($ctxId);
$dirty = false;
foreach (['collecting', '__foreach', '__cap'] as $stateKey) {
if (isset($m[$stateKey])) { unset($m[$stateKey]); $dirty = true; }
}
if ($dirty) ConversationContext::updateMetadata($ctxId, $m);
// "menu/inicio/volver" → prefer per-type greeting_menu over global show_main_menu
$greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
$action = $greetingMenuKey;
}
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);
}
// 2d. api_vars: collecting URL variables before executing an api_report endpoint
if (!empty($meta['__api_vars'])) {
return self::handleApiVarsInput($meta, $input, $context, $company, $ctxId, $flows, $menus);
}
// 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. Welcome message — shown once for brand-new sessions before the category menu
$welcomeMenuKey = $config['welcome_menu'] ?? null;
if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company);
}
// 4b. 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 (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
// 8. Static fallback text
if (!$suppressFallback) {
$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;
$validIds = $col['__valid_ids'] ?? null;
if ($validIds !== null && !in_array(trim($input), $validIds, true)) {
return self::sendText(
'⚠️ Por favor selecciona una opción válida de la lista.',
$context['from'], $company
);
}
$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
);
}
$valueField = $flow['value_field'] ?? 'id';
$meta['collecting'] = [
'meta_group' => $flow['meta_group'] ?? '',
'meta_key' => $flow['meta_key'] ?? '',
'next_node' => $flow['next_node'] ?? null,
'__valid_ids' => array_column($items, $valueField),
];
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
{
// Priority: __ep_vars_combined (vars collected/resolved for this endpoint call)
$epVars = $meta['__ep_vars_combined'] ?? [];
foreach ($epVars as $key => $value) {
$url = str_replace('{' . $key . '}', urlencode((string)$value), $url);
}
// Then the rest of meta groups
foreach ($meta as $group => $fields) {
if (!is_array($fields) || str_starts_with($group, '__')) 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::buildUrl(self::substituteUrlVars($ep['url'], $meta), $company);
$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 {data:[...]}, {datos:[...]}, or bare array
if (isset($data['data']) && is_array($data['data'])) return $data['data'];
if (isset($data['datos']) && is_array($data['datos'])) return $data['datos'];
return $data;
}
private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array
{
$valueField = $flow['value_field'] ?? 'id';
$labelField = $flow['label_field'] ?? 'name';
// Auto-detect multi-section if items carry a 'section' key
$sectionField = $flow['section_field'] ?? (isset($items[0]['section']) ? 'section' : null);
if ($sectionField !== null) {
$grouped = [];
foreach ($items as $item) {
$id = (string)($item[$valueField] ?? '');
if ($id === '') continue;
$title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
$sec = mb_substr((string)($item[$sectionField] ?? 'Opciones'), 0, 24);
$grouped[$sec][] = ['id' => $id, 'title' => $title];
}
$sections = [];
foreach (array_slice($grouped, 0, 10, true) as $secTitle => $rows) {
$sections[] = ['title' => $secTitle, 'rows' => array_slice($rows, 0, 10)];
}
} else {
$rows = [];
foreach (array_slice($items, 0, 10) as $item) {
$id = (string)($item[$valueField] ?? '');
if ($id === '') continue;
$rows[] = ['id' => $id, 'title' => mb_substr((string)($item[$labelField] ?? $id), 0, 24)];
}
$sections = [['title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows]];
}
if (empty($sections)) return null;
return self::enqueueInteractive($to, [
'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' => $sections,
],
], $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(self::buildUrl($ep['url'], $company));
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, $ctxId);
}
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];
$value = trim($input);
// ── 1. Awaiting manual text after user picked "Otra opción" ──────────
if (!empty($cap['__awaiting_other'])) {
if (($field['other_validate'] ?? '') === 'date'
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return self::sendText(
'⚠️ Formato inválido. ' . ($field['other_prompt'] ?? 'Ingresa la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):'),
$context['from'], $company
);
}
$cap['collected'][$field['key']] = $value;
$cap['collected_labels'][$field['key']] = $value;
unset($cap['__awaiting_other']);
$cap['index'] = $idx + 1;
while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; }
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
}
// ── 2. User chose "Otra opción" — ask for manual text ────────────────
if ($value === '__cap_other') {
$cap['__awaiting_other'] = true;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
$prompt = $field['other_prompt'] ?? '✏️ Ingresa el valor manualmente:';
return self::sendText("*{$cap['header']}*\n\n{$prompt}", $context['from'], $company);
}
// ── 3. Lookup field — search entity by entered value (e.g. cédula) ───
if (($field['type'] ?? 'text') === 'lookup') {
$result = self::callLookupEndpoint(
$field['lookup_endpoint_key'] ?? '',
$value,
$field['lookup_param'] ?? 'q',
$company
);
if ($result === null) {
$notFound = $field['not_found_text'] ?? '⚠️ No encontré resultados. Intenta de nuevo:';
return self::sendText("*{$cap['header']}*\n\n{$notFound}", $context['from'], $company);
}
$displayName = (string)($result[$field['display_field'] ?? 'nombre'] ?? 'Desconocido');
$entityId = (string)($result[$field['value_field'] ?? 'id'] ?? '');
$cap['__lookup_pending'] = ['id' => $entityId, 'label' => $displayName];
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
return self::enqueueInteractive($context['from'], [
'type' => 'button',
'body' => ['text' => "¿Es *{$displayName}*?"],
'action' => ['buttons' => [
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_yes', 'title' => '✅ Sí']],
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_no', 'title' => '❌ No, reintentar']],
]],
], $company);
}
// ── 4. Normal save ────────────────────────────────────────────────────
$cap['collected'][$field['key']] = $value;
if (($field['type'] ?? 'text') === 'select') {
$cap['collected_labels'][$field['key']] =
$cap['__selects'][$field['key']][$value] ?? $value;
}
$cap['index'] = $idx + 1;
while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; }
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
}
private static function capShowConfirmOrSubmit(array $cap, array $meta, array $context, array $company, int $ctxId): ?array
{
$fields = $cap['fields'];
if ($cap['confirm']) {
$summary = "*{$cap['header']}*\n\n";
foreach ($cap['collected'] as $k => $v) {
$label = self::capFieldLabel($fields, $k);
$display = $cap['collected_labels'][$k] ?? $v;
$summary .= "• {$label}: *{$display}*\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 callLookupEndpoint(string $epKey, string $value, string $param, array $company): ?array
{
if ($epKey === '' || $value === '') return null;
$stmt = db()->prepare('SELECT url FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1');
$stmt->execute([(int)$company['id'], $epKey]);
$ep = $stmt->fetch();
if (!$ep || empty($ep['url'])) return null;
$url = self::buildUrl($ep['url'], $company) . '&' . urlencode($param) . '=' . urlencode($value);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . ($company['api_key'] ?? ''),
'X-API-Key: ' . ($company['api_key'] ?? ''),
],
]);
$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);
return (is_array($data) && ($data['status'] ?? '') === '1') ? ($data['datos'] ?? null) : null;
}
private static function capAskNext(array $cap, string $to, array $company, int $ctxId = 0): ?array
{
$idx = (int)$cap['index'];
$total = count($cap['fields']);
$field = $cap['fields'][$idx];
$type = $field['type'] ?? 'text';
// ── select: fetch from API ───────────────────────────────────────────
if ($type === 'select') {
$vf = $field['value_field'] ?? 'id';
$lf = $field['label_field'] ?? 'label';
$items = self::fetchDynamicList($field['source_endpoint_key'] ?? '', $company, $cap['collected'] ?? []);
if (!empty($items)) {
if ($ctxId > 0) {
$cap['__selects'][$field['key']] = array_column($items, $lf, $vf);
$m = ConversationContext::getMetadata($ctxId);
$m['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $m);
}
return self::buildDynamicListResponse($items, [
'value_field' => $vf,
'label_field' => $lf,
'header' => $cap['header'],
'body' => $field['prompt'] ?? ('Selecciona ' . ($field['label'] ?? $field['key'])),
'button' => 'Ver opciones',
'section_title' => $field['label'] ?? $field['key'],
], $to, $company);
}
}
// ── date_quick: Hoy / Ayer / Anteayer / Otra ────────────────────────
if ($type === 'date_quick') {
$hoy = date('Y-m-d');
$ayer = date('Y-m-d', strtotime('-1 day'));
$ant = date('Y-m-d', strtotime('-2 days'));
return self::enqueueInteractive($to, [
'type' => 'list',
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
'body' => ['text' => $field['prompt'] ?? '📅 ¿Cuál es la fecha?'],
'footer' => ['text' => ''],
'action' => ['button' => 'Ver fechas', 'sections' => [[
'title' => 'Fecha',
'rows' => [
['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy],
['id' => $ayer, 'title' => '📅 Ayer', 'description' => $ayer],
['id' => $ant, 'title' => '📅 Anteayer', 'description' => $ant],
['id' => '__cap_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'],
],
]]],
], $company);
}
// ── static_select: inline options ───────────────────────────────────
if ($type === 'static_select') {
$options = $field['options'] ?? [];
$rows = array_map(fn($o) => [
'id' => (string)$o['id'],
'title' => mb_substr((string)$o['label'], 0, 24),
], $options);
if (count($rows) <= 3) {
return self::enqueueInteractive($to, [
'type' => 'button',
'body' => ['text' => "*{$cap['header']}*\n\n" . ($field['prompt'] ?? 'Selecciona:')],
'action' => ['buttons' => array_map(fn($r) => [
'type' => 'reply', 'reply' => ['id' => $r['id'], 'title' => $r['title']],
], $rows)],
], $company);
}
return self::enqueueInteractive($to, [
'type' => 'list',
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
'body' => ['text' => $field['prompt'] ?? 'Selecciona una opción:'],
'footer' => ['text' => ''],
'action' => ['button' => 'Ver opciones', 'sections' => [[
'title' => $field['label'] ?? 'Opciones',
'rows' => $rows,
]]],
], $company);
}
// ── lookup / text: plain text prompt ────────────────────────────────
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
return self::sendText($text, $to, $company);
}
// skip_if: {"field":"novedad_id","not_in":["35","41"]} → skip this field if collected[field] not in list
private static function capShouldSkip(array $field, array $collected): bool
{
$si = $field['skip_if'] ?? null;
if (!$si) return false;
$val = (string)($collected[$si['field'] ?? ''] ?? '');
if (isset($si['not_in'])) return !in_array($val, array_map('strval', $si['not_in']));
if (isset($si['equals'])) return $val !== (string)$si['equals'];
return false;
}
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(self::buildUrl($ep['url'], $company));
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,
};
}
// ── Resolves fixed-mode var values ───────────────────────────────────────
private static function resolveFixedVar(string $value): string
{
return match ($value) {
'current_date' => date('Y-m-d'),
'month_start' => date('Y-m-01'),
'month_end' => date('Y-m-t'),
'last_7_start' => date('Y-m-d', strtotime('-7 days')),
'last_30_start' => date('Y-m-d', strtotime('-30 days')),
'year_start' => date('Y-01-01'),
default => $value,
};
}
// ── Handles sequential collection of URL vars before calling endpoint ───
private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array
{
$nav = "\n\nEscribe *menu* para ver opciones o *salir* para terminar.";
$state = $meta['__api_vars'];
$pending = $state['pending'] ?? [];
$collected = $state['collected'] ?? [];
$epKey = $state['endpoint_key'] ?? '';
$epParams = $state['ep_params'] ?? [];
// Save the user's answer for the current question
if (!empty($pending)) {
$current = array_shift($pending);
$collected[$current['key']] = trim($input);
$state['pending'] = $pending;
$state['collected'] = $collected;
}
// If more questions remain, ask the next one
if (!empty($state['pending'])) {
$next = $state['pending'][0];
$meta['__api_vars'] = $state;
ConversationContext::updateMetadata($ctxId, $meta);
return self::sendText($next['prompt'] . $nav, $context['from'], $company);
}
// All vars collected — inject into meta and execute
unset($meta['__api_vars']);
foreach ($collected as $k => $v) {
$meta['__ep_vars'][$k] = $v;
}
ConversationContext::updateMetadata($ctxId, $meta);
return self::executeApiReport(array_merge($epParams, ['endpoint_key' => $epKey]), $context, $company, $ctxId);
}
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, params 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);
}
// ── Resolve URL vars from params config ──────────────────────────────
$varConfigs = json_decode($ep['params'] ?? '[]', true) ?: [];
$meta = ConversationContext::getMetadata($ctxId);
// Inject any previously collected vars into meta for substituteUrlVars
$epVars = $meta['__ep_vars'] ?? [];
if (!empty($epVars)) {
$meta['__ep_vars_flat'] = $epVars;
}
$askVars = [];
foreach ($varConfigs as $vc) {
$key = $vc['key'] ?? '';
$mode = $vc['mode'] ?? 'ask';
if ($key === '') continue;
if ($mode === 'fixed') {
// Inject fixed value directly into meta for substituteUrlVars
$meta['__ep_vars_flat'][$key] = self::resolveFixedVar($vc['value'] ?? '');
} elseif ($mode === 'ask') {
// Only ask if not already collected
if (!isset($epVars[$key])) {
$askVars[] = ['key' => $key, 'prompt' => $vc['prompt'] ?? "Ingresa el valor para {$key}:"];
} else {
$meta['__ep_vars_flat'][$key] = $epVars[$key];
}
}
}
// If there are vars to ask, start collection flow
if (!empty($askVars)) {
$first = array_shift($askVars);
$meta['__api_vars'] = [
'endpoint_key' => $endpointKey,
'ep_params' => $params,
'pending' => $askVars,
'collected' => [],
];
// Clean collected vars so they don't bleed into the next call
unset($meta['__ep_vars']);
ConversationContext::updateMetadata($ctxId, $meta);
return self::sendText($first['prompt'] . $nav, $context['from'], $company);
}
// Flatten collected vars into meta for substituteUrlVars
if (!empty($meta['__ep_vars_flat'])) {
$meta['__ep_vars_combined'] = $meta['__ep_vars_flat'];
}
unset($meta['__ep_vars'], $meta['__ep_vars_flat']);
ConversationContext::updateMetadata($ctxId, $meta);
$url = self::buildUrl(
self::substituteUrlVars($ep['url'], array_merge($meta, ['__ep_vars_combined' => $meta['__ep_vars_combined'] ?? []])),
$company
);
$method = strtoupper($ep['method'] ?? 'GET');
$apiKey = $company['api_key'] ?? '';
$extraQuery = $params['query'] ?? [];
$dateMode = $params['date_mode'] ?? '';
if ($dateMode === 'today') {
$extraQuery['fecha_desde'] = date('Y-m-d');
$extraQuery['fecha_hasta'] = date('Y-m-d');
} elseif ($dateMode === 'last_30') {
$extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-30 days'));
$extraQuery['fecha_hasta'] = date('Y-m-d');
} elseif ($dateMode === 'current_month') {
$extraQuery['fecha_desde'] = date('Y-m-01');
$extraQuery['fecha_hasta'] = date('Y-m-d');
} elseif ($dateMode === 'last_month') {
$extraQuery['fecha_desde'] = date('Y-m-01', strtotime('first day of last month'));
$extraQuery['fecha_hasta'] = date('Y-m-t', strtotime('last day of last month'));
}
$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);
}
// Si el ERP devuelve JSON (ej: sin_datos), informar al usuario en texto
if (str_starts_with($contentType ?? '', 'application/json')) {
$json = json_decode($content, true);
$msg = $json['mensaje'] ?? ($json['message'] ?? 'No hay datos disponibles para el período solicitado.');
ConversationContext::reset($ctxId);
return self::sendText('️ ' . $msg . $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 generado.' . $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: confirm/cancel, lookup confirm/reject, or list/button field pick
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);
}
// Lookup confirmation buttons
if (!empty($meta['__cap']['__lookup_pending'])) {
if ($input === '__cap_lookup_yes') {
$cap = $meta['__cap'];
$fields = $cap['fields'];
$idx = (int)$cap['index'];
$field = $fields[$idx];
$pending = $cap['__lookup_pending'];
$cap['collected'][$field['key']] = $pending['id'];
$cap['collected_labels'][$field['key']] = $pending['label'];
unset($cap['__lookup_pending']);
$cap['index'] = $idx + 1;
while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; }
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
}
if ($input === '__cap_lookup_no') {
$cap = $meta['__cap'];
unset($cap['__lookup_pending']);
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
}
// Interactive list/button response for select, date_quick, static_select fields
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
}
// 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)
// Redirect show_main_menu → per-type greeting_menu if configured
$resolvedInput = $input;
$greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($resolvedInput === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
$resolvedInput = $greetingMenuKey;
}
if (isset($flows[$resolvedInput])) {
ConversationContext::updateNode($ctxId, $resolvedInput);
return self::handleFlow($flows[$resolvedInput], $context, $company, $ctxId, $menus);
}
if (isset($menus[$resolvedInput])) {
ConversationContext::updateNode($ctxId, $resolvedInput);
return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company);
}
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 buildUrl(string $relOrAbs, array $company): string
{
if ($relOrAbs === '' || str_starts_with($relOrAbs, 'http')) return $relOrAbs;
return rtrim($company['api_base_url'] ?? '', '/') . '/' . ltrim($relOrAbs, '/');
}
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);
}
}