Endpoint destino {$sfEpSelHtml}
Grupo del formulario
@@ -2721,7 +2867,7 @@ HTML;
}
echo <<
- + Agregar flujo
+ + Agregar flujo
@@ -2935,13 +3081,18 @@ function addRow(btn, mi, si) {
function flowTabChange(sel) {
const card = sel.closest('.item-card');
const v = sel.value;
- const map = {
- 'ftg-text': v==='text', 'ftg-menu': v==='menu', 'ftg-fn': v==='function',
- 'ftg-collect': v==='collect_input', 'ftg-dynlist': v==='dynamic_list', 'ftg-submit': v==='submit_form'
+ // flex sections need display:flex; block sections use '' (default block)
+ const sections = {
+ 'ftg-text': {show: v==='text', flex: false},
+ 'ftg-menu': {show: v==='menu', flex: false},
+ 'ftg-fn': {show: v==='function', flex: false},
+ 'ftg-collect': {show: v==='collect_input', flex: true},
+ 'ftg-dynlist': {show: v==='dynamic_list', flex: true},
+ 'ftg-submit': {show: v==='submit_form', flex: true},
};
- for (const [cls, show] of Object.entries(map)) {
+ for (const [cls, cfg] of Object.entries(sections)) {
const el = card.querySelector('.' + cls);
- if (el) el.style.display = show ? 'flex' : 'none';
+ if (el) el.style.display = cfg.show ? (cfg.flex ? 'flex' : '') : 'none';
}
if (v !== 'function') {
const ep = card.querySelector('.ftg-ep');
@@ -2955,6 +3106,101 @@ function flowFnTabChange(sel) {
if (ep) ep.style.display = sel.value === 'api_report' ? 'flex' : 'none';
}
+function addFlowCard() {
+ const container = document.getElementById('flowContainer');
+ if (!container) return;
+
+ const menus = CONFIG?.menus || {};
+ let menuOpts = '
— Selecciona menú — ';
+ for (const k of Object.keys(menus)) menuOpts += `
📑 ${k} `;
+
+ const eps = ENDPOINTS || {};
+ let epOpts = '
— Ninguno — ';
+ for (const [k, ep] of Object.entries(eps)) epOpts += `
${ep.name || k} `;
+
+ const flows = CONFIG?.flows || {};
+ let nnOpts = '
— Ninguno — ';
+ for (const k of Object.keys(flows)) nnOpts += `
${k} `;
+
+ container.insertAdjacentHTML('beforeend', `
+
`);
+ container.lastElementChild.scrollIntoView({behavior:'smooth'});
+}
+
function updatePreview() {
const g = document.getElementById('prevGreeting')?.value || '';
const f = document.getElementById('prevFallback')?.value || '';
diff --git a/public/index.php b/public/index.php
index 8cfa247..f497b97 100644
--- a/public/index.php
+++ b/public/index.php
@@ -274,8 +274,9 @@ $routes = [
['POST', '/admin/company/phones/sync', fn() => DashboardController::companyPhonesSync()],
// ─── Endpoints API por empresa ─────────────────────────────────────────
- ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()],
- ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()],
+ ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()],
+ ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()],
+ ['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()],
// ─── Admin: listar pendientes de aprobación ─────────────────────────────
['GET', '/admin/pending-list', fn() => DashboardController::pendingList()],
diff --git a/services/NormalBot.php b/services/NormalBot.php
index 2af5602..ad9237c 100644
--- a/services/NormalBot.php
+++ b/services/NormalBot.php
@@ -41,14 +41,15 @@ class NormalBot
}
// 3. Active node — resume conversation
- if ($currentNode !== null && $currentNode !== 'collecting' && isset($flows[$currentNode])) {
+ $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 (new user / no active context)
+ // 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, null);
+ ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
@@ -72,9 +73,9 @@ class NormalBot
return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus);
}
- // 7. Greeting menu as fallback
+ // 7. Greeting menu as fallback (texto no reconocido después de estar en __greeted)
if ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
- ConversationContext::updateNode($ctxId, null);
+ ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
@@ -133,7 +134,8 @@ class NormalBot
private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array
{
- $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company);
+ $meta = ConversationContext::getMetadata($ctxId);
+ $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
if ($items === null || count($items) === 0) {
ConversationContext::reset($ctxId);
@@ -143,7 +145,6 @@ class NormalBot
);
}
- $meta = ConversationContext::getMetadata($ctxId);
$meta['collecting'] = [
'meta_group' => $flow['meta_group'] ?? '',
'meta_key' => $flow['meta_key'] ?? '',
@@ -155,7 +156,19 @@ class NormalBot
return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
}
- private static function fetchDynamicList(string $endpointKey, array $company): ?array
+ // 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;
@@ -167,7 +180,8 @@ class NormalBot
if (!$ep || empty($ep['url'])) return null;
$apiKey = $company['api_key'] ?? '';
- $ch = curl_init($ep['url']);
+ $url = self::substituteUrlVars($ep['url'], $meta);
+ $ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
@@ -307,7 +321,8 @@ class NormalBot
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company);
}
- $url = $ep['url'];
+ $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'] ?? '';