feat: dynamic lists, multi-step forms, improved flow editor
NormalBot new flow types: - collect_input: asks user a question, saves answer to metadata - dynamic_list: calls endpoint to get options, displays WhatsApp list, saves selection to metadata; max 10 rows - submit_form: reads accumulated metadata fields and sends to api_report Commands always take priority (escape from any collecting state). Interactive selections during collecting state are caught before the static menu lookup. Admin flow editor redesign: - All fields use select dropdowns (menus, endpoints, next-node) - Grouped options: Respuesta / Navegar / Acción directa / Formulario paso a paso - collect_input, dynamic_list, submit_form panels with contextual fields - addNewFlow() opens blank modal; saveFlow() handles all types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
12ec5d28b1
commit
316d9ce73e
+278
-75
@@ -2582,7 +2582,7 @@ HTML;
|
||||
}
|
||||
echo <<<HTML
|
||||
</div>
|
||||
<button type="button" class="add-btn" onclick="addFlow()">+ Agregar flujo</button>
|
||||
<button type="button" class="add-btn" onclick="addNewFlow()">+ Agregar flujo</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
||||
@@ -3298,95 +3298,264 @@ function deleteMenu(key) {
|
||||
}
|
||||
|
||||
// ─── Edit Flow ───
|
||||
function buildFlowModal(key, flow) {
|
||||
const isNew = !key;
|
||||
const menuOpts = Object.keys(CONFIG.menus).map(m =>
|
||||
'<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>📑 ' + m + '</option>'
|
||||
).join('');
|
||||
|
||||
const allTargets = [
|
||||
...Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>📑 Menú: ' + m + '</option>'),
|
||||
].join('');
|
||||
|
||||
const epOpts = Object.keys(ENDPOINTS).map(k =>
|
||||
'<option value="' + k + '"' + (((flow.params||{}).endpoint_key) === k ? ' selected' : '') +
|
||||
'>' + k + '</option>'
|
||||
).join('');
|
||||
|
||||
// Dynamic-list source endpoint
|
||||
const dlEpOpts = Object.keys(ENDPOINTS).map(k =>
|
||||
'<option value="' + k + '"' + ((flow.source_endpoint_key) === k ? ' selected' : '') +
|
||||
'>' + k + '</option>'
|
||||
).join('');
|
||||
|
||||
// Next-node options (other flows)
|
||||
const flowOpts = Object.keys(CONFIG.flows).filter(f => f !== key).map(f =>
|
||||
'<option value="' + f + '">' + f + '</option>'
|
||||
).join('');
|
||||
|
||||
const t = flow.type || 'text';
|
||||
const fn = flow.function || '';
|
||||
const dateMode = (flow.params||{}).date_mode || '';
|
||||
const caption = (flow.params||{}).caption || '';
|
||||
const filename = (flow.params||{}).filename || '';
|
||||
const metaGroup = flow.meta_group || '';
|
||||
const metaKey = flow.meta_key || '';
|
||||
const nextNode = flow.next_node || '';
|
||||
const prompt = flow.prompt || '';
|
||||
const header = flow.header || '';
|
||||
const body = flow.body || '';
|
||||
const secTitle = flow.section_title || '';
|
||||
const valField = flow.value_field || 'id';
|
||||
const lblField = flow.label_field || 'nombre';
|
||||
|
||||
const showFn = t === 'function';
|
||||
const showEp = t === 'function' && fn === 'api_report';
|
||||
const showCollect = t === 'collect_input';
|
||||
const showDynList = t === 'dynamic_list';
|
||||
const showSubmit = t === 'submit_form';
|
||||
|
||||
return \`
|
||||
<h2>\${isNew ? '➕ Nuevo Flujo' : '🔀 Editar Flujo: ' + key}</h2>
|
||||
<div class="form-group">
|
||||
<label>ID del flujo</label>
|
||||
<input type="text" id="editFlowKey" value="\${key}" placeholder="ej: ver_cosecha">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>¿Qué hace este flujo?</label>
|
||||
<select id="editFlowType" onchange="flowTypeToggle()">
|
||||
<optgroup label="Respuesta">
|
||||
<option value="text" \${t==='text' ?'selected':''}>💬 Texto fijo</option>
|
||||
</optgroup>
|
||||
<optgroup label="Navegar">
|
||||
<option value="menu" \${t==='menu' ?'selected':''}>📑 Mostrar menú</option>
|
||||
</optgroup>
|
||||
<optgroup label="Acción directa">
|
||||
<option value="function" \${t==='function' ?'selected':''}>⚡ Función especial</option>
|
||||
</optgroup>
|
||||
<optgroup label="Formulario paso a paso">
|
||||
<option value="collect_input"\${t==='collect_input'?'selected':''}>✏️ Pedir dato al usuario</option>
|
||||
<option value="dynamic_list" \${t==='dynamic_list' ?'selected':''}>📋 Lista dinámica (desde endpoint)</option>
|
||||
<option value="submit_form" \${t==='submit_form' ?'selected':''}>🚀 Enviar formulario a endpoint</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- texto fijo -->
|
||||
<div id="fgText" style="\${t==='text'?'':'display:none'}">
|
||||
<div class="form-group"><label>Mensaje</label>
|
||||
<textarea id="editFlowMessage" rows="3" placeholder="Texto que recibirá el usuario">\${flow.message||''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- menú -->
|
||||
<div id="fgMenu" style="\${t==='menu'?'':'display:none'}">
|
||||
<div class="form-group"><label>Menú a mostrar</label>
|
||||
<select id="editFlowMenu"><option value="">— Selecciona menú —</option>\${menuOpts}</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- función especial -->
|
||||
<div id="fgFunc" style="\${showFn?'':'display:none'}">
|
||||
<div class="form-group"><label>Función</label>
|
||||
<select id="editFlowFunction" onchange="flowFuncToggle()">
|
||||
<option value="goodbye" \${fn==='goodbye' ?'selected':''}>↩️ Salir / Goodbye</option>
|
||||
<option value="api_report" \${fn==='api_report' ?'selected':''}>📄 Descargar informe (api_report)</option>
|
||||
<option value="forward_to_ai" \${fn==='forward_to_ai' ?'selected':''}>🤖 Escalar a IA</option>
|
||||
<option value="forward_to_agent"\${fn==='forward_to_agent'?'selected':''}>👤 Escalar a agente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="fgEp" style="\${showEp?'':'display:none'}">
|
||||
<div class="form-group"><label>Endpoint</label>
|
||||
<select id="editFlowEpKey">
|
||||
<option value="">— Selecciona —</option>\${epOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Periodo de fecha</label>
|
||||
<select id="editFlowDateMode">
|
||||
<option value="" \${dateMode==='' ?'selected':''}>Sin fecha</option>
|
||||
<option value="today" \${dateMode==='today' ?'selected':''}>Hoy</option>
|
||||
<option value="last_30"\${dateMode==='last_30'?'selected':''}>Últimos 30 días</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Nombre archivo</label>
|
||||
<input type="text" id="editFlowFilename" value="\${filename}" placeholder="reporte.pdf">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Mensaje al enviar</label>
|
||||
<input type="text" id="editFlowCaption" value="\${caption}" placeholder="Aquí tienes el informe.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- collect_input -->
|
||||
<div id="fgCollect" style="\${showCollect?'':'display:none'}">
|
||||
<div class="form-group"><label>Pregunta al usuario</label>
|
||||
<input type="text" id="editFlowPrompt" value="\${prompt}" placeholder="¿Desde qué fecha? (YYYY-MM-DD)">
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Guardar en grupo</label>
|
||||
<input type="text" id="editFlowMetaGroup" value="\${metaGroup}" placeholder="form_produccion_lotes">
|
||||
</div>
|
||||
<div><label>Guardar en campo</label>
|
||||
<input type="text" id="editFlowMetaKey" value="\${metaKey}" placeholder="desde">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Flujo siguiente</label>
|
||||
<select id="editFlowNextNode">
|
||||
<option value="">— Ninguno (terminar) —</option>\${flowOpts}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dynamic_list -->
|
||||
<div id="fgDynList" style="\${showDynList?'':'display:none'}">
|
||||
<div class="form-group"><label>Endpoint que devuelve la lista</label>
|
||||
<select id="editFlowDlEp">
|
||||
<option value="">— Selecciona endpoint —</option>\${dlEpOpts}
|
||||
</select>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Debe devolver un array JSON: [{id, nombre}, ...]</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Campo de valor (ID)</label>
|
||||
<input type="text" id="editFlowValField" value="\${valField}" placeholder="id">
|
||||
</div>
|
||||
<div><label>Campo de etiqueta</label>
|
||||
<input type="text" id="editFlowLblField" value="\${lblField}" placeholder="nombre">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Encabezado lista</label>
|
||||
<input type="text" id="editFlowHeader" value="\${header}" placeholder="Selecciona la finca">
|
||||
</div>
|
||||
<div><label>Título sección</label>
|
||||
<input type="text" id="editFlowSecTitle" value="\${secTitle}" placeholder="Fincas">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Cuerpo del mensaje</label>
|
||||
<input type="text" id="editFlowBody" value="\${body}" placeholder="¿De qué finca necesitas el informe?">
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Guardar en grupo</label>
|
||||
<input type="text" id="editFlowDlMetaGroup" value="\${metaGroup}" placeholder="form_produccion_finca">
|
||||
</div>
|
||||
<div><label>Guardar en campo</label>
|
||||
<input type="text" id="editFlowDlMetaKey" value="\${metaKey}" placeholder="finca_id">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Flujo siguiente</label>
|
||||
<select id="editFlowDlNext">
|
||||
<option value="">— Ninguno —</option>\${flowOpts}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- submit_form -->
|
||||
<div id="fgSubmit" style="\${showSubmit?'':'display:none'}">
|
||||
<div class="form-group"><label>Endpoint destino</label>
|
||||
<select id="editFlowSubmitEp">
|
||||
<option value="">— Selecciona endpoint —</option>\${epOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Grupo del formulario (debe coincidir con collect_input / dynamic_list)</label>
|
||||
<input type="text" id="editFlowSubmitGroup" value="\${metaGroup}" placeholder="form_produccion_lotes">
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Nombre archivo</label>
|
||||
<input type="text" id="editFlowSubmitFilename" value="\${filename}" placeholder="reporte.pdf">
|
||||
</div>
|
||||
<div><label>Mensaje al enviar</label>
|
||||
<input type="text" id="editFlowSubmitCaption" value="\${caption}" placeholder="Aquí tienes el informe.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
\${isNew ? '' : '<button class="btn-del-modal" onclick="deleteFlow(\'' + key + '\')">Eliminar</button>'}
|
||||
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
|
||||
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function editFlow(key) {
|
||||
const flow = CONFIG.flows[key];
|
||||
if (!flow) return;
|
||||
const menuOpts = Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>' + m + '</option>').join('');
|
||||
const epOpts = Object.keys(ENDPOINTS).map(k => '<option value="' + k + '"' + (((flow.params||{}).endpoint_key) === k ? ' selected' : '') + '>' + k + ' — ' + (ENDPOINTS[k].url||'').slice(0,50) + '</option>').join('');
|
||||
const isApiReport = flow.function === 'api_report';
|
||||
const dateMode = (flow.params||{}).date_mode || '';
|
||||
const caption = (flow.params||{}).caption || '';
|
||||
const filename = (flow.params||{}).filename || '';
|
||||
showModal(\`
|
||||
<h2>🔀 Editar Flujo: \${key}</h2>
|
||||
<div class="form-group"><label>ID del flujo</label><input type="text" id="editFlowKey" value="\${key}"></div>
|
||||
<div class="form-group"><label>Tipo</label>
|
||||
<select id="editFlowType" onchange="flowTypeToggle()">
|
||||
<option value="text"\${flow.type==='text'?' selected':''}>Texto (respuesta fija)</option>
|
||||
<option value="function"\${flow.type==='function'?' selected':''}>Función (acción especial)</option>
|
||||
<option value="menu"\${flow.type==='menu'?' selected':''}>Menú (submenú)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="flowTextGroup" style="\${flow.type==='text'?'':'display:none'}">
|
||||
<label>Mensaje de respuesta</label>
|
||||
<textarea id="editFlowMessage" rows="3">\${flow.message||''}</textarea>
|
||||
</div>
|
||||
<div class="form-group" id="flowFuncGroup" style="\${flow.type==='function'?'':'display:none'}">
|
||||
<label>Función</label>
|
||||
<select id="editFlowFunction" onchange="flowFuncToggle()">
|
||||
<option value="forward_to_ai"\${flow.function==='forward_to_ai'?' selected':''}>Escalar a IA</option>
|
||||
<option value="forward_to_agent"\${flow.function==='forward_to_agent'?' selected':''}>Escalar a agente humano</option>
|
||||
<option value="api_report"\${flow.function==='api_report'?' selected':''}>Descargar / enviar informe</option>
|
||||
<option value="goodbye"\${flow.function==='goodbye'?' selected':''}>Salir (goodbye)</option>
|
||||
<option value="webhook"\${flow.function==='webhook'?' selected':''}>Llamar webhook externo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="flowEpGroup" style="\${isApiReport?'':'display:none'}">
|
||||
<div class="form-group">
|
||||
<label>Endpoint</label>
|
||||
<select id="editFlowEpKey">
|
||||
<option value="">— Selecciona endpoint —</option>\${epOpts}
|
||||
</select>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Configura los endpoints en la pestaña "Endpoints API" de la empresa</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Periodo</label>
|
||||
<select id="editFlowDateMode">
|
||||
<option value="" \${dateMode===''?'selected':''}>Sin fecha</option>
|
||||
<option value="today" \${dateMode==='today'?'selected':''}>Hoy</option>
|
||||
<option value="last_30" \${dateMode==='last_30'?'selected':''}>Últimos 30 días</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Nombre archivo</label>
|
||||
<input type="text" id="editFlowFilename" value="\${filename}" placeholder="reporte.pdf">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Mensaje al enviar</label>
|
||||
<input type="text" id="editFlowCaption" value="\${caption}" placeholder="Aquí tienes el informe solicitado.">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="flowMenuGroup" style="\${flow.type==='menu'?'':'display:none'}">
|
||||
<label>Sub-menú a mostrar</label>
|
||||
<select id="editFlowMenu"><option value="">Selecciona...</option>\${menuOpts}</select>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
|
||||
<button class="btn-del-modal" onclick="deleteFlow('\${key}')">Eliminar</button>
|
||||
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
|
||||
</div>\`);
|
||||
document.getElementById('modalContent').innerHTML = buildFlowModal(key, flow);
|
||||
document.getElementById('modalOverlay').classList.add('show');
|
||||
// Restore next-node selection for collect_input
|
||||
const nn = flow.next_node || '';
|
||||
const nnSel = document.getElementById('editFlowNextNode');
|
||||
if (nnSel) { for (const o of nnSel.options) if (o.value === nn) { o.selected = true; break; } }
|
||||
const dnSel = document.getElementById('editFlowDlNext');
|
||||
if (dnSel) { for (const o of dnSel.options) if (o.value === flow.next_node||'') { o.selected = true; break; } }
|
||||
}
|
||||
|
||||
function addNewFlow() {
|
||||
document.getElementById('modalContent').innerHTML = buildFlowModal('', {type:'text'});
|
||||
document.getElementById('modalOverlay').classList.add('show');
|
||||
}
|
||||
|
||||
function flowTypeToggle() {
|
||||
const t = document.getElementById('editFlowType').value;
|
||||
document.getElementById('flowTextGroup').style.display = t === 'text' ? '' : 'none';
|
||||
document.getElementById('flowFuncGroup').style.display = t === 'function' ? '' : 'none';
|
||||
document.getElementById('flowMenuGroup').style.display = t === 'menu' ? '' : 'none';
|
||||
if (t !== 'function') document.getElementById('flowEpGroup').style.display = 'none';
|
||||
document.getElementById('fgText').style.display = t === 'text' ? '' : 'none';
|
||||
document.getElementById('fgMenu').style.display = t === 'menu' ? '' : 'none';
|
||||
document.getElementById('fgFunc').style.display = t === 'function' ? '' : 'none';
|
||||
document.getElementById('fgCollect').style.display = t === 'collect_input' ? '' : 'none';
|
||||
document.getElementById('fgDynList').style.display = t === 'dynamic_list' ? '' : 'none';
|
||||
document.getElementById('fgSubmit').style.display = t === 'submit_form' ? '' : 'none';
|
||||
if (t !== 'function') document.getElementById('fgEp').style.display = 'none';
|
||||
}
|
||||
|
||||
function flowFuncToggle() {
|
||||
const fn = document.getElementById('editFlowFunction').value;
|
||||
document.getElementById('flowEpGroup').style.display = fn === 'api_report' ? '' : 'none';
|
||||
document.getElementById('fgEp').style.display = fn === 'api_report' ? '' : 'none';
|
||||
}
|
||||
|
||||
function saveFlow(oldKey) {
|
||||
const newKey = document.getElementById('editFlowKey').value.trim();
|
||||
const type = document.getElementById('editFlowType').value;
|
||||
if (!newKey) { alert('El ID del flujo es requerido'); return; }
|
||||
const type = document.getElementById('editFlowType').value;
|
||||
const flow = { type };
|
||||
if (type === 'text') flow.message = document.getElementById('editFlowMessage').value.trim();
|
||||
else if (type === 'function') {
|
||||
|
||||
if (type === 'text') {
|
||||
flow.message = document.getElementById('editFlowMessage').value.trim();
|
||||
|
||||
} else if (type === 'menu') {
|
||||
flow.menu = document.getElementById('editFlowMenu').value;
|
||||
|
||||
} else if (type === 'function') {
|
||||
flow.function = document.getElementById('editFlowFunction').value;
|
||||
if (flow.function === 'api_report') {
|
||||
flow.params = {
|
||||
@@ -3396,10 +3565,44 @@ function saveFlow(oldKey) {
|
||||
filename: document.getElementById('editFlowFilename').value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
} else if (type === 'collect_input') {
|
||||
flow.prompt = document.getElementById('editFlowPrompt').value.trim();
|
||||
flow.meta_group = document.getElementById('editFlowMetaGroup').value.trim();
|
||||
flow.meta_key = document.getElementById('editFlowMetaKey').value.trim();
|
||||
flow.next_node = document.getElementById('editFlowNextNode').value || null;
|
||||
|
||||
} else if (type === 'dynamic_list') {
|
||||
flow.source_endpoint_key = document.getElementById('editFlowDlEp').value;
|
||||
flow.value_field = document.getElementById('editFlowValField').value.trim() || 'id';
|
||||
flow.label_field = document.getElementById('editFlowLblField').value.trim() || 'nombre';
|
||||
flow.header = document.getElementById('editFlowHeader').value.trim();
|
||||
flow.body = document.getElementById('editFlowBody').value.trim();
|
||||
flow.section_title = document.getElementById('editFlowSecTitle').value.trim();
|
||||
flow.meta_group = document.getElementById('editFlowDlMetaGroup').value.trim();
|
||||
flow.meta_key = document.getElementById('editFlowDlMetaKey').value.trim();
|
||||
flow.next_node = document.getElementById('editFlowDlNext').value || null;
|
||||
|
||||
} else if (type === 'submit_form') {
|
||||
flow.endpoint_key = document.getElementById('editFlowSubmitEp').value;
|
||||
flow.meta_group = document.getElementById('editFlowSubmitGroup').value.trim();
|
||||
flow.filename = document.getElementById('editFlowSubmitFilename').value.trim();
|
||||
flow.caption = document.getElementById('editFlowSubmitCaption').value.trim();
|
||||
}
|
||||
else if (type === 'menu') flow.menu = document.getElementById('editFlowMenu').value;
|
||||
delete CONFIG.flows[oldKey];
|
||||
|
||||
if (oldKey && oldKey !== newKey) delete CONFIG.flows[oldKey];
|
||||
CONFIG.flows[newKey] = flow;
|
||||
// Sync menu row IDs if key changed
|
||||
for (const mk in CONFIG.menus) {
|
||||
const menu = CONFIG.menus[mk];
|
||||
for (const s of (menu.sections || [])) {
|
||||
for (const r of (s.rows || [])) {
|
||||
if (r.id === oldKey && oldKey !== newKey) r.id = newKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveConfig();
|
||||
}
|
||||
// Update menu rows that reference this flow
|
||||
for (const mk in CONFIG.menus) {
|
||||
const menu = CONFIG.menus[mk];
|
||||
|
||||
+296
-140
@@ -5,7 +5,7 @@ class NormalBot
|
||||
{
|
||||
public static function process(array $company, array $context, string $input): ?array
|
||||
{
|
||||
$config = self::getConfig($company);
|
||||
$config = self::getConfig($company);
|
||||
$permType = (string)($company['_permission_type'] ?? 1);
|
||||
$perType = $config['per_type'][$permType] ?? [];
|
||||
|
||||
@@ -13,46 +13,46 @@ class NormalBot
|
||||
$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'];
|
||||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||||
$ctxId = (int)$botCtx['id'];
|
||||
$currentNode = $botCtx['current_node'];
|
||||
$normalized = self::normalize($input);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentNode !== null && isset($flows[$currentNode])) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 3. Active node — resume conversation
|
||||
if ($currentNode !== null && $currentNode !== 'collecting' && 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;
|
||||
}
|
||||
|
||||
// Per-type greeting_menu: direct menu for this category (greeting + fallback in one)
|
||||
// 4. Per-type greeting menu (new user / no active context)
|
||||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||||
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
|
||||
ConversationContext::updateNode($ctxId, null);
|
||||
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';
|
||||
@@ -60,24 +60,25 @@ class NormalBot
|
||||
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);
|
||||
}
|
||||
|
||||
// greeting_menu also serves as fallback when nothing else matched
|
||||
// 7. Greeting menu as fallback
|
||||
if ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
|
||||
ConversationContext::updateNode($ctxId, null);
|
||||
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);
|
||||
@@ -87,12 +88,164 @@ class NormalBot
|
||||
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
|
||||
{
|
||||
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company);
|
||||
|
||||
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 = 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::buildDynamicListResponse($items, $flow, $context['from'], $company);
|
||||
}
|
||||
|
||||
private static function fetchDynamicList(string $endpointKey, array $company): ?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'] ?? '';
|
||||
$ch = curl_init($ep['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);
|
||||
}
|
||||
|
||||
// ── 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];
|
||||
}
|
||||
if (isset($allMenus[$menuRef])) return $allMenus[$menuRef];
|
||||
$config = self::getConfig($company);
|
||||
return ($config['menus'] ?? [])[$menuRef] ?? [];
|
||||
}
|
||||
@@ -104,14 +257,19 @@ class NormalBot
|
||||
$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,
|
||||
'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),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
private static function executeFunction(string $function, array $params, array $context, array $company, int $ctxId): ?array
|
||||
{
|
||||
return match ($function) {
|
||||
@@ -124,8 +282,8 @@ class NormalBot
|
||||
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,
|
||||
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,23 +307,23 @@ class NormalBot
|
||||
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||||
}
|
||||
|
||||
$url = $ep['url'];
|
||||
$url = $ep['url'];
|
||||
$method = strtoupper($ep['method'] ?? 'GET');
|
||||
$apiKey = $company['api_key'] ?? '';
|
||||
|
||||
$extraQuery = $params['query'] ?? [];
|
||||
$dateMode = $params['date_mode'] ?? '';
|
||||
$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['fecha_fin'] = date('Y-m-d');
|
||||
}
|
||||
$extraQuery['telefono'] = $context['from'];
|
||||
$extraQuery['nombre'] = $context['name'] ?? '';
|
||||
$extraQuery['nombre'] = $context['name'] ?? '';
|
||||
|
||||
$glue = str_contains($url, '?') ? '&' : '?';
|
||||
$url .= $glue . http_build_query($extraQuery);
|
||||
$glue = str_contains($url, '?') ? '&' : '?';
|
||||
$url .= $glue . http_build_query($extraQuery);
|
||||
|
||||
$ch = curl_init($url);
|
||||
$curlOpts = [
|
||||
@@ -179,14 +337,14 @@ class NormalBot
|
||||
],
|
||||
];
|
||||
if ($method === 'POST') {
|
||||
$curlOpts[CURLOPT_POST] = true;
|
||||
$curlOpts[CURLOPT_POST] = true;
|
||||
$curlOpts[CURLOPT_POSTFIELDS] = '{}';
|
||||
}
|
||||
curl_setopt_array($ch, $curlOpts);
|
||||
$content = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$content = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
$error = curl_error($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
@@ -202,19 +360,18 @@ class NormalBot
|
||||
}
|
||||
|
||||
$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'],
|
||||
'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';
|
||||
$ext = 'pdf';
|
||||
$mime = 'application/pdf';
|
||||
foreach ($mimeMap as $m => $info) {
|
||||
if (str_starts_with($contentType ?? '', $m)) {
|
||||
$ext = $info[0];
|
||||
$mime = $info[1] ?? $m;
|
||||
[$ext, $mime] = $info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -236,39 +393,93 @@ class NormalBot
|
||||
}
|
||||
|
||||
$reportName = $params['filename'] ?? ('reporte.' . $ext);
|
||||
$caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
|
||||
$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);
|
||||
}
|
||||
|
||||
// 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',
|
||||
'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),
|
||||
],
|
||||
'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),
|
||||
'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20),
|
||||
'sections' => [],
|
||||
],
|
||||
];
|
||||
@@ -277,14 +488,14 @@ class NormalBot
|
||||
$rows = [];
|
||||
foreach ($section['rows'] ?? [] as $row) {
|
||||
$rows[] = [
|
||||
'id' => mb_substr($row['id'] ?? '', 0, 200),
|
||||
'title' => mb_substr($row['title'] ?? '', 0, 24),
|
||||
'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,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -295,112 +506,57 @@ class NormalBot
|
||||
$buttons = [];
|
||||
foreach (array_slice($menu['buttons'] ?? [], 0, 3) as $btn) {
|
||||
$buttons[] = [
|
||||
'type' => 'reply',
|
||||
'type' => 'reply',
|
||||
'reply' => [
|
||||
'id' => mb_substr($btn['id'] ?? '', 0, 256),
|
||||
'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),
|
||||
],
|
||||
'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
|
||||
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]),
|
||||
];
|
||||
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]),
|
||||
];
|
||||
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]),
|
||||
];
|
||||
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);
|
||||
$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);
|
||||
$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
|
||||
private static function log(string $msg): void
|
||||
{
|
||||
$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;
|
||||
if (class_exists('WpWebhook')) WpWebhook::log('NORMALBOT', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user