← back to Answer Cockpit
lib/menu.js
35 lines
'use strict';
// menu.js — server-side answer resolution against a LIVE AskUserQuestion menu.
//
// Proven live 2026-09-15 (TK-11793): a real menu selects by its DISPLAYED NUMBER. Typing the
// option LABEL (or any free text) + Enter fires the cursor default (option 1). A stale browser
// tab, a curl script, or a peer agent can still POST a label — so the SERVER, which parsed the
// menu, must be the last line: translate a label to its number, and refuse free text.
//
// resolveMenuAnswer(text, question) → { text, translated, optionLabel, error }
// - question == null (no live menu) → pass through unchanged
// - text is a number matching an option n → ok
// - text equals an option label (ci, trim) → text := String(n), translated:true
// - anything else → error (free text would mis-answer)
function norm(s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); }
function resolveMenuAnswer(text, question) {
const q = question && question.questions && question.questions[0];
const opts = q && Array.isArray(q.options) ? q.options : [];
if (!q || !opts.length) return { text, translated: false, optionLabel: null, error: null };
const t = String(text || '').trim();
const nums = opts.map((o, i) => (Number.isInteger(o.n) ? o.n : i + 1));
if (/^\d{1,2}$/.test(t) && nums.includes(parseInt(t, 10))) {
const o = opts[nums.indexOf(parseInt(t, 10))];
return { text: t, translated: false, optionLabel: o.label || null, error: null };
}
const idx = opts.findIndex((o) => norm(o.label) === norm(t));
if (idx >= 0) return { text: String(nums[idx]), translated: true, optionLabel: opts[idx].label, error: null };
return {
text, translated: false, optionLabel: null,
error: `a live menu is on screen — send the option number (${nums.join('/')}) or an exact label; free text + Enter would fire the cursor default (option ${nums[0]})`,
};
}
module.exports = { resolveMenuAnswer };