← 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 };