feat: collect_and_post flow type — sequential field collection + POST
New flow type that asks the user for a configured list of fields one
by one, shows a summary with confirm/cancel, then POSTs all answers
as JSON to the configured endpoint with Bearer auth.
Config example:
{
"type": "collect_and_post",
"endpoint_key": "subir_cosecha",
"header": "🌿 Ciclos Cosecha",
"confirm": true,
"success_text": "✅ Ciclo registrado.",
"fields": [
{"key":"fecha", "label":"Fecha", "prompt":"📅 ¿Fecha del ciclo? (YYYY-MM-DD)"},
{"key":"cantidad", "label":"Racimos", "prompt":"🔢 ¿Cantidad de racimos?"},
{"key":"finca", "label":"Finca", "prompt":"🏡 ¿Nombre de la finca?"}
]
}
POST payload: {fecha, cantidad, finca, telefono, nombre}
Also adds the UI panel in bot-config with dynamic add/remove field rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
32f87ef27c
commit
3c7ea9fdf9
@@ -2921,6 +2921,32 @@ HTML;
|
||||
$showDynList = $ft === 'dynamic_list' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
|
||||
$showSubmit = $ft === 'submit_form' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
|
||||
$showForEach = $ft === 'collect_for_each' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
|
||||
$showCap = $ft === 'collect_and_post' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
|
||||
|
||||
// collect_and_post fields
|
||||
$capFields = $flow['fields'] ?? [];
|
||||
$capHeader = self::h($flow['header'] ?? '');
|
||||
$capSucc = self::h($flow['success_text'] ?? '');
|
||||
$capConfirm = !empty($flow['confirm']);
|
||||
$capEpKey = self::h($flow['endpoint_key'] ?? '');
|
||||
$capEpSelHtml = '<option value="">— Selecciona endpoint —</option>';
|
||||
foreach ($companyEndpoints as $ep) {
|
||||
$sel = $capEpKey === self::h($ep['endpoint_key']) ? ' selected' : '';
|
||||
$capEpSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
|
||||
}
|
||||
$capFieldsHtml = '';
|
||||
foreach ($capFields as $cf) {
|
||||
$ck = self::h($cf['key'] ?? '');
|
||||
$cl = self::h($cf['label'] ?? '');
|
||||
$cp = self::h($cf['prompt'] ?? '');
|
||||
$capFieldsHtml .= "
|
||||
<div class=\"cap-field-row\" style=\"display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:6px;align-items:center\">
|
||||
<input type=\"text\" name=\"cap_field_key[]\" value=\"{$ck}\" placeholder=\"clave (ej: fecha)\" style=\"font-family:monospace\">
|
||||
<input type=\"text\" name=\"cap_field_label[]\" value=\"{$cl}\" placeholder=\"etiqueta\">
|
||||
<input type=\"text\" name=\"cap_field_prompt[]\" value=\"{$cp}\" placeholder=\"Pregunta al usuario\">
|
||||
<button type=\"button\" onclick=\"this.closest('.cap-field-row').remove()\" style=\"background:#fee2e2;border:none;border-radius:4px;padding:4px 8px;cursor:pointer\">✕</button>
|
||||
</div>";
|
||||
}
|
||||
|
||||
$feEpSelHtml = '<option value="">— Selecciona endpoint lista —</option>';
|
||||
foreach ($companyEndpoints as $ep) {
|
||||
@@ -2968,6 +2994,7 @@ HTML;
|
||||
<option value=\"collect_input\"" . ($ft==='collect_input' ?' selected':'') . ">✏️ Pedir dato</option>
|
||||
<option value=\"dynamic_list\"" . ($ft==='dynamic_list' ?' selected':'') . ">📋 Lista dinámica</option>
|
||||
<option value=\"collect_for_each\"" . ($ft==='collect_for_each'?' selected':'') . ">🌧 Registro por ítem</option>
|
||||
<option value=\"collect_and_post\"" . ($ft==='collect_and_post'?' selected':'') . ">📝 Pedir campos y enviar</option>
|
||||
<option value=\"submit_form\"" . ($ft==='submit_form' ?' selected':'') . ">🚀 Enviar formulario</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
@@ -3053,6 +3080,26 @@ HTML;
|
||||
<div><label>Mensaje de éxito</label><input type=\"text\" name=\"flow_fe_success[]\" value=\"{$feSuccessText}\" placeholder=\"✅ Datos registrados correctamente.\"></div>
|
||||
</div>
|
||||
|
||||
<!-- collect_and_post -->
|
||||
<div class=\"ftg-cap\" style=\"{$showCap}\">
|
||||
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
|
||||
<div><label>Endpoint (POST)</label><select name=\"flow_cap_ep[]\">{$capEpSelHtml}</select></div>
|
||||
<div><label>Encabezado</label><input type=\"text\" name=\"flow_cap_header[]\" value=\"{$capHeader}\" placeholder=\"📋 Registro ciclos\"></div>
|
||||
</div>
|
||||
<div style=\"display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center\">
|
||||
<div><label>Mensaje de éxito</label><input type=\"text\" name=\"flow_cap_success[]\" value=\"{$capSucc}\" placeholder=\"✅ Registrado.\"></div>
|
||||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;margin-top:18px\">
|
||||
<input type=\"checkbox\" name=\"flow_cap_confirm_{$flowIdx}\" value=\"1\"" . ($capConfirm?' checked':'') . "> Pedir confirmación
|
||||
</label>
|
||||
</div>
|
||||
<input type=\"hidden\" name=\"flow_cap_confirm_idx[]\" value=\"{$flowIdx}\">
|
||||
<div>
|
||||
<label style=\"margin-bottom:6px;display:block\">Campos a pedir <span style=\"font-size:10px;color:#9ca3af\">(clave · etiqueta · pregunta)</span></label>
|
||||
<div class=\"cap-fields-container\" id=\"cap-fields-{$flowIdx}\">{$capFieldsHtml}</div>
|
||||
<button type=\"button\" onclick=\"addCapField('cap-fields-{$flowIdx}')\" style=\"margin-top:6px;font-size:12px;background:#f0f4ff;border:1px solid #c7d7ff;border-radius:4px;padding:4px 10px;cursor:pointer\">+ Campo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
@@ -3316,6 +3363,7 @@ function flowTabChange(sel) {
|
||||
'ftg-dynlist': {show: v==='dynamic_list', flex: true},
|
||||
'ftg-submit': {show: v==='submit_form', flex: true},
|
||||
'ftg-foreach': {show: v==='collect_for_each', flex: true},
|
||||
'ftg-cap': {show: v==='collect_and_post', flex: true},
|
||||
};
|
||||
for (const [cls, cfg] of Object.entries(sections)) {
|
||||
const el = card.querySelector('.' + cls);
|
||||
@@ -3367,6 +3415,7 @@ function addFlowCard() {
|
||||
' <option value="collect_input">✏️ Pedir dato</option>' +
|
||||
' <option value="dynamic_list">📋 Lista dinámica</option>' +
|
||||
' <option value="collect_for_each">🌧 Registro por ítem</option>' +
|
||||
' <option value="collect_and_post">📝 Pedir campos y enviar</option>' +
|
||||
' <option value="submit_form">🚀 Enviar formulario</option>' +
|
||||
' </optgroup>' +
|
||||
' </select>' +
|
||||
@@ -3436,6 +3485,22 @@ function addFlowCard() {
|
||||
' <div><label>Encabezado</label><input type="text" name="flow_fe_header[]" placeholder="🌧 Pluviometría"></div>' +
|
||||
' <div><label>Pregunta por ítem</label><input type="text" name="flow_fe_question[]" placeholder="¿Cuál es el valor? (mm)"></div>' +
|
||||
' <div><label>Mensaje de éxito</label><input type="text" name="flow_fe_success[]" placeholder="✅ Datos registrados."></div>' +
|
||||
' <div class="ftg-cap" style="display:none;flex-direction:column;gap:8px">' +
|
||||
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
|
||||
' <div><label>Endpoint (POST)</label><select name="flow_cap_ep[]">' + epOpts + '</select></div>' +
|
||||
' <div><label>Encabezado</label><input type="text" name="flow_cap_header[]" placeholder="📋 Registro ciclos"></div>' +
|
||||
' </div>' +
|
||||
' <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center">' +
|
||||
' <div><label>Mensaje de éxito</label><input type="text" name="flow_cap_success[]" placeholder="✅ Registrado."></div>' +
|
||||
' <label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:18px"><input type="checkbox" name="flow_cap_confirm_new" value="1" checked> Confirmar</label>' +
|
||||
' </div>' +
|
||||
' <input type="hidden" name="flow_cap_confirm_idx[]" value="new">' +
|
||||
' <div>' +
|
||||
' <label style="margin-bottom:6px;display:block">Campos <span style="font-size:10px;color:#9ca3af">(clave · etiqueta · pregunta)</span></label>' +
|
||||
' <div class="cap-fields-container" id="cap-fields-new-' + cardId + '"></div>' +
|
||||
' <button type="button" onclick="addCapField(\'cap-fields-new-' + cardId + '\')" style="margin-top:6px;font-size:12px;background:#f0f4ff;border:1px solid #c7d7ff;border-radius:4px;padding:4px 10px;cursor:pointer">+ Campo</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
@@ -3444,6 +3509,20 @@ function addFlowCard() {
|
||||
container.lastElementChild.scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
|
||||
function addCapField(containerId) {
|
||||
const c = document.getElementById(containerId);
|
||||
if (!c) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cap-field-row';
|
||||
row.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:6px;align-items:center;margin-bottom:4px';
|
||||
row.innerHTML =
|
||||
'<input type="text" name="cap_field_key[]" placeholder="clave" style="font-family:monospace">' +
|
||||
'<input type="text" name="cap_field_label[]" placeholder="etiqueta">' +
|
||||
'<input type="text" name="cap_field_prompt[]" placeholder="Pregunta al usuario">' +
|
||||
'<button type="button" onclick="this.closest(\'.cap-field-row\').remove()" style="background:#fee2e2;border:none;border-radius:4px;padding:4px 8px;cursor:pointer">✕</button>';
|
||||
c.appendChild(row);
|
||||
}
|
||||
|
||||
function aiProviderChange(v) {
|
||||
const oai = document.getElementById('ai-openai-fields');
|
||||
const gem = document.getElementById('ai-gemini-fields');
|
||||
|
||||
@@ -421,6 +421,13 @@ $routes = [
|
||||
$flowFeHeaders = $_POST['flow_fe_header'] ?? [];
|
||||
$flowFeQs = $_POST['flow_fe_question'] ?? [];
|
||||
$flowFeSucTxts = $_POST['flow_fe_success'] ?? [];
|
||||
$flowCapEps = $_POST['flow_cap_ep'] ?? [];
|
||||
$flowCapHdrs = $_POST['flow_cap_header'] ?? [];
|
||||
$flowCapSuccs = $_POST['flow_cap_success'] ?? [];
|
||||
$flowCapCfmIdx = $_POST['flow_cap_confirm_idx'] ?? [];
|
||||
$capFieldKeys = $_POST['cap_field_key'] ?? [];
|
||||
$capFieldLbls = $_POST['cap_field_label'] ?? [];
|
||||
$capFieldPrmts = $_POST['cap_field_prompt'] ?? [];
|
||||
|
||||
$flows = [];
|
||||
foreach ($flowKeys as $i => $fk) {
|
||||
@@ -481,6 +488,31 @@ $routes = [
|
||||
$f['header'] = trim($flowFeHeaders[$i] ?? '');
|
||||
$f['question'] = trim($flowFeQs[$i] ?? '');
|
||||
$f['success_text'] = trim($flowFeSucTxts[$i] ?? '');
|
||||
|
||||
} elseif ($ftype === 'collect_and_post') {
|
||||
$confirmIdx = $flowCapCfmIdx[$i] ?? (string)$i;
|
||||
$confirmKey = 'flow_cap_confirm_' . $confirmIdx;
|
||||
$fields = [];
|
||||
// cap_field_* arrays are flat across ALL cap flows — we read only
|
||||
// rows belonging to this card by matching the container's position.
|
||||
// Since PHP flattens them, we track by finding all entries in sequence.
|
||||
// Simple approach: take all submitted field rows (they are per-card
|
||||
// because the form only has one submit, and cap-field rows are inside
|
||||
// the card's container — browser sends them in DOM order).
|
||||
foreach ($capFieldKeys as $fi => $fkey) {
|
||||
$fkey = trim($fkey);
|
||||
if ($fkey === '') continue;
|
||||
$fields[] = [
|
||||
'key' => $fkey,
|
||||
'label' => trim($capFieldLbls[$fi] ?? $fkey),
|
||||
'prompt' => trim($capFieldPrmts[$fi] ?? ''),
|
||||
];
|
||||
}
|
||||
$f['endpoint_key'] = trim($flowCapEps[$i] ?? '');
|
||||
$f['header'] = trim($flowCapHdrs[$i] ?? '');
|
||||
$f['success_text'] = trim($flowCapSuccs[$i] ?? '');
|
||||
$f['confirm'] = !empty($_POST[$confirmKey]);
|
||||
$f['fields'] = $fields;
|
||||
}
|
||||
|
||||
$flows[$fk] = $f;
|
||||
|
||||
@@ -45,6 +45,11 @@ class NormalBot
|
||||
return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows);
|
||||
}
|
||||
|
||||
// 2c. collect_and_post: collecting sequential fields then POSTing
|
||||
if (!empty($meta['__cap'])) {
|
||||
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
|
||||
}
|
||||
|
||||
// 3. Active node — resume conversation
|
||||
$sentinels = ['collecting', '__greeted'];
|
||||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
||||
@@ -385,6 +390,143 @@ class NormalBot
|
||||
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 (empty($fields)) {
|
||||
ConversationContext::reset($ctxId);
|
||||
return self::sendText('⚠️ Flujo sin campos configurados. Contacta al administrador.', $context['from'], $company);
|
||||
}
|
||||
|
||||
$meta = ConversationContext::getMetadata($ctxId);
|
||||
$meta['__cap'] = [
|
||||
'fields' => $fields,
|
||||
'index' => 0,
|
||||
'collected' => [],
|
||||
'endpoint_key' => $flow['endpoint_key'] ?? '',
|
||||
'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.',
|
||||
'confirm' => (bool)($flow['confirm'] ?? true),
|
||||
'header' => $flow['header'] ?? '📋 Registro',
|
||||
];
|
||||
ConversationContext::updateMetadata($ctxId, $meta);
|
||||
|
||||
return self::capAskNext($meta['__cap'], $context['from'], $company);
|
||||
}
|
||||
|
||||
private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array
|
||||
{
|
||||
$cap = $meta['__cap'];
|
||||
$fields = $cap['fields'];
|
||||
$idx = (int)$cap['index'];
|
||||
$field = $fields[$idx];
|
||||
|
||||
// Save answer
|
||||
$cap['collected'][$field['key']] = trim($input);
|
||||
$cap['index'] = $idx + 1;
|
||||
$meta['__cap'] = $cap;
|
||||
ConversationContext::updateMetadata($ctxId, $meta);
|
||||
|
||||
// More fields?
|
||||
if ($cap['index'] < count($fields)) {
|
||||
return self::capAskNext($cap, $context['from'], $company);
|
||||
}
|
||||
|
||||
// All done — confirm or submit directly
|
||||
if ($cap['confirm']) {
|
||||
$summary = "*{$cap['header']}*\n\n";
|
||||
foreach ($cap['collected'] as $k => $v) {
|
||||
$label = self::capFieldLabel($fields, $k);
|
||||
$summary .= "• {$label}: *{$v}*\n";
|
||||
}
|
||||
$summary .= "\n¿Confirmas el registro?";
|
||||
|
||||
return self::enqueueInteractive($context['from'], [
|
||||
'type' => 'button',
|
||||
'body' => ['text' => $summary],
|
||||
'action' => ['buttons' => [
|
||||
['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']],
|
||||
['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']],
|
||||
]],
|
||||
], $company);
|
||||
}
|
||||
|
||||
return self::submitCapPost($meta, $context, $company, $ctxId);
|
||||
}
|
||||
|
||||
private static function capAskNext(array $cap, string $to, array $company): ?array
|
||||
{
|
||||
$idx = (int)$cap['index'];
|
||||
$total = count($cap['fields']);
|
||||
$field = $cap['fields'][$idx];
|
||||
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
|
||||
|
||||
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
|
||||
return self::sendText($text, $to, $company);
|
||||
}
|
||||
|
||||
private static function capFieldLabel(array $fields, string $key): string
|
||||
{
|
||||
foreach ($fields as $f) {
|
||||
if (($f['key'] ?? '') === $key) return $f['label'] ?? $f['key'];
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
private static function submitCapPost(array $meta, array $context, array $company, int $ctxId): ?array
|
||||
{
|
||||
$cap = $meta['__cap'];
|
||||
$endpointKey = $cap['endpoint_key'] ?? '';
|
||||
$nav = "\n\nEscribe *menu* para ver más opciones.";
|
||||
|
||||
unset($meta['__cap']);
|
||||
ConversationContext::updateMetadata($ctxId, $meta);
|
||||
ConversationContext::reset($ctxId);
|
||||
|
||||
if ($endpointKey === '') {
|
||||
return self::sendText('⚠️ Endpoint no configurado.' . $nav, $context['from'], $company);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
|
||||
$stmt->execute([(int)$company['id'], $endpointKey]);
|
||||
$ep = $stmt->fetch();
|
||||
|
||||
if (!$ep || empty($ep['url'])) {
|
||||
return self::sendText('⚠️ Endpoint no encontrado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||||
}
|
||||
|
||||
$apiKey = $company['api_key'] ?? '';
|
||||
$payload = array_merge($cap['collected'], [
|
||||
'telefono' => $context['from'],
|
||||
'nombre' => $context['name'] ?? '',
|
||||
]);
|
||||
|
||||
$ch = curl_init($ep['url']);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'X-API-Key: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlErr || $httpCode >= 400) {
|
||||
self::log("collect_and_post error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
|
||||
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||||
}
|
||||
|
||||
return self::sendText($cap['success_text'] . $nav, $context['from'], $company);
|
||||
}
|
||||
|
||||
// ── Submit form (end of multi-step collection) ───────────────────────────
|
||||
|
||||
private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
|
||||
@@ -431,6 +573,7 @@ class NormalBot
|
||||
'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,
|
||||
};
|
||||
}
|
||||
@@ -595,6 +738,20 @@ class NormalBot
|
||||
return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
|
||||
}
|
||||
|
||||
// collect_and_post confirmation (confirm/cancel button)
|
||||
if (!empty($meta['__cap'])) {
|
||||
if ($input === '__cap_confirm') {
|
||||
return self::submitCapPost($meta, $context, $company, $ctxId);
|
||||
}
|
||||
if ($input === '__cap_cancel') {
|
||||
$m = ConversationContext::getMetadata($ctxId);
|
||||
unset($m['__cap']);
|
||||
ConversationContext::updateMetadata($ctxId, $m);
|
||||
ConversationContext::reset($ctxId);
|
||||
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
|
||||
}
|
||||
}
|
||||
|
||||
// collect_for_each confirmation (confirm/cancel button)
|
||||
if (!empty($meta['__foreach'])) {
|
||||
if ($input === '__foreach_confirm') {
|
||||
|
||||
Reference in New Issue
Block a user