Let bots ask multiple-choice questions before acting
ask_user puts up to six questions on one card (radios for one pick, checkboxes for several, an optional typed Other) as a [question:ID] chip. The user's answers are checked against the questions, stored in bot_questions and sent to the bot as their chat message; Skip says so. Offered in chat replies and user-started tasks, never in routines, hook calls or chatroom sessions. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { BotQuestionView } from "@/src/types";
|
||||
import botQuestionAnswersText from "./bot-question-answers-text";
|
||||
|
||||
const view: BotQuestionView = {
|
||||
id: 4,
|
||||
title: "Before I deploy",
|
||||
questions: [
|
||||
{ question: "Which environment?", kind: "single", options: ["Staging", "Production"], allow_other: false },
|
||||
{ question: "Which checks?", kind: "multiple", options: ["Lint", "Tests", "Build"], allow_other: true },
|
||||
],
|
||||
answers: [
|
||||
{ choices: [0], other: "" },
|
||||
{ choices: [0, 2], other: "e2e" },
|
||||
],
|
||||
status: "answered",
|
||||
bot_id: 2,
|
||||
bot_name: "Ember",
|
||||
};
|
||||
|
||||
describe("botQuestionAnswersText", () => {
|
||||
test("names each question with what was picked", () => {
|
||||
expect(botQuestionAnswersText({ view })).toBe(
|
||||
[
|
||||
"My answers (questions #4):",
|
||||
"- **Which environment?** Staging",
|
||||
"- **Which checks?** Lint; Build; other: e2e",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("says when the questions were skipped", () => {
|
||||
expect(botQuestionAnswersText({ view: { ...view, status: "skipped", answers: null } })).toBe(
|
||||
"I skipped your questions (questions #4) without answering them.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { BotQuestionView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
view: BotQuestionView;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the chat tells the bot once the user answered (or skipped) its
|
||||
* questions, so it carries on from their answers: each question with the
|
||||
* options picked and anything typed into "Other".
|
||||
*/
|
||||
export default function botQuestionAnswersText({ view }: Params) {
|
||||
if (view.status == "skipped" || !view.answers) {
|
||||
return `I skipped your questions (questions #${view.id}) without answering them.`;
|
||||
}
|
||||
|
||||
const lines = view.questions.map((item, index) => {
|
||||
const answer = view.answers?.[index];
|
||||
const picked = (answer?.choices || []).map((choice) => item.options[choice]).filter(Boolean);
|
||||
const other = answer?.other ? `other: ${answer.other}` : "";
|
||||
|
||||
return `- **${item.question}** ${[...picked, other].filter(Boolean).join("; ")}`;
|
||||
});
|
||||
|
||||
return [`My answers (questions #${view.id}):`, ...lines].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
type Params = {
|
||||
id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a bot names questions it asked the user: `[question:4]`. The chat
|
||||
* turns it into a link (`linkComputerRefs`) that renders as a chip opening
|
||||
* the card the user answers them in.
|
||||
*/
|
||||
export default function botQuestionRef({ id }: Params) {
|
||||
return `[question:${id}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link target a question ref becomes, e.g. `#question-4`.
|
||||
*/
|
||||
export const BotQuestionRefHrefPrefix = "#question-";
|
||||
|
||||
/**
|
||||
* The id in a question ref's link target, or null.
|
||||
*/
|
||||
export function botQuestionRefId({ href }: { href?: string | null }) {
|
||||
if (!href?.startsWith(BotQuestionRefHrefPrefix)) return null;
|
||||
|
||||
const id = Number(href.slice(BotQuestionRefHrefPrefix.length));
|
||||
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { BotQuestionItem } from "@/src/types";
|
||||
import checkBotQuestionAnswers from "./check-bot-question-answers";
|
||||
|
||||
const questions: BotQuestionItem[] = [
|
||||
{ question: "Which environment?", kind: "single", options: ["Staging", "Production"], allow_other: false },
|
||||
{ question: "Which checks?", kind: "multiple", options: ["Lint", "Tests", "Build"], allow_other: true },
|
||||
];
|
||||
|
||||
describe("checkBotQuestionAnswers", () => {
|
||||
test("tidies valid answers", () => {
|
||||
expect(
|
||||
checkBotQuestionAnswers({
|
||||
questions,
|
||||
answers: [{ choices: [1] }, { choices: [2, 0, 2], other: " e2e " }],
|
||||
}),
|
||||
).toEqual([
|
||||
{ choices: [1], other: "" },
|
||||
{ choices: [0, 2], other: "e2e" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("takes Other alone where it's allowed", () => {
|
||||
expect(checkBotQuestionAnswers({ questions, answers: [{ choices: [0] }, { choices: [], other: "None" }] })[1]).toEqual({
|
||||
choices: [],
|
||||
other: "None",
|
||||
});
|
||||
});
|
||||
|
||||
test("needs one answer per question", () => {
|
||||
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [0] }] })).toThrow("Answer every question.");
|
||||
});
|
||||
|
||||
test("needs every question answered", () => {
|
||||
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [0] }, { choices: [], other: " " }] })).toThrow(
|
||||
"Answer question 2.",
|
||||
);
|
||||
});
|
||||
|
||||
test("takes one pick for a single-choice question", () => {
|
||||
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [0, 1] }, { choices: [0] }] })).toThrow(
|
||||
"Question 1 takes one answer.",
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses options that don't exist", () => {
|
||||
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [2] }, { choices: [0] }] })).toThrow(
|
||||
"Question 1 has an option that doesn't exist.",
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses Other where it isn't allowed", () => {
|
||||
expect(() =>
|
||||
checkBotQuestionAnswers({ questions, answers: [{ choices: [], other: "QA" }, { choices: [0] }] }),
|
||||
).toThrow("Question 1 only takes its options.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
|
||||
import AppError from "./app-error";
|
||||
|
||||
type Params = {
|
||||
questions: BotQuestionItem[];
|
||||
answers: { choices: number[]; other?: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's answers checked against the questions they answer: one per
|
||||
* question, only options that exist, "Other" only where it's allowed,
|
||||
* exactly one pick for a single-choice question and at least one for a
|
||||
* multiple-choice one. Returns them tidied (choices sorted and unique,
|
||||
* "Other" trimmed); throws an `AppError` naming the first question that
|
||||
* isn't answered right.
|
||||
*/
|
||||
export default function checkBotQuestionAnswers({ questions, answers }: Params): BotQuestionAnswer[] {
|
||||
if (answers.length != questions.length) {
|
||||
throw new AppError(`Answer every question.`);
|
||||
}
|
||||
|
||||
return questions.map((item, index) => {
|
||||
const answer = answers[index]!;
|
||||
const choices = [...new Set(answer.choices)].sort((a, b) => a - b);
|
||||
const other = (answer.other || "").trim();
|
||||
const label = `Question ${index + 1}`;
|
||||
|
||||
if (choices.some((choice) => !Number.isInteger(choice) || choice < 0 || choice >= item.options.length)) {
|
||||
throw new AppError(`${label} has an option that doesn't exist.`);
|
||||
}
|
||||
|
||||
if (other && !item.allow_other) {
|
||||
throw new AppError(`${label} only takes its options.`);
|
||||
}
|
||||
|
||||
const picked = choices.length + (other ? 1 : 0);
|
||||
|
||||
if (!picked) throw new AppError(`Answer ${label.toLowerCase()}.`);
|
||||
|
||||
if (item.kind == "single" && picked > 1) {
|
||||
throw new AppError(`${label} takes one answer.`);
|
||||
}
|
||||
|
||||
return { choices, other };
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,10 @@ describe("exportMessageText", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("words questions by number", () => {
|
||||
expect(exportMessageText({ text: "Pick: [question:4]", ...names, heading_offset: 0 })).toBe("Pick: (questions 4)");
|
||||
});
|
||||
|
||||
test("words graphics by number", () => {
|
||||
expect(exportMessageText({ text: "See [graphic:5]", ...names, heading_offset: 0 })).toBe("See (graphic 5)");
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ function wordRefs({
|
||||
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
|
||||
.replace(/\[secret:(\d+)\](?!\()/g, "(secret $1, hidden)")
|
||||
.replace(/\[secret-request:(\d+)\](?!\()/g, "(secret request $1)")
|
||||
.replace(/\[question:(\d+)\](?!\()/g, "(questions $1)")
|
||||
.replace(/\[graphic:(\d+)\](?!\()/g, "(graphic $1)")
|
||||
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
|
||||
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
|
||||
|
||||
@@ -7,6 +7,7 @@ import hookProposalRef from "./hook-proposal-ref";
|
||||
import proposalRef from "./proposal-ref";
|
||||
import secretRef from "./secret-ref";
|
||||
import secretRequestRef from "./secret-request-ref";
|
||||
import botQuestionRef from "./bot-question-ref";
|
||||
import takeOverRef from "./take-over-ref";
|
||||
|
||||
describe("linkComputerRefs", () => {
|
||||
@@ -42,6 +43,13 @@ describe("linkComputerRefs", () => {
|
||||
).toBe("[secret 12](#secret-ref-12) and [secret request 4](#secret-request-4)");
|
||||
});
|
||||
|
||||
test("links questions a bot asked, also when quoted alone in code", () => {
|
||||
expect(linkComputerRefs({ text: `Before I start: ${botQuestionRef({ id: 4 })}` })).toBe(
|
||||
"Before I start: [questions 4](#question-4)",
|
||||
);
|
||||
expect(linkComputerRefs({ text: "See `[question:4]`" })).toBe("See [questions 4](#question-4)");
|
||||
});
|
||||
|
||||
test("links take-over requests", () => {
|
||||
expect(
|
||||
linkComputerRefs({
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SecretRequestRefHrefPrefix = "#secret-request-";
|
||||
/**
|
||||
* The kinds of ref a message can carry, as written before the colon.
|
||||
*/
|
||||
const RefKinds = "computer|take-over|bot|workspace|proposal|hook-proposal|secret|secret-request|graphic|room|file|folder";
|
||||
const RefKinds = "computer|take-over|bot|workspace|proposal|hook-proposal|secret|secret-request|question|graphic|room|file|folder";
|
||||
|
||||
/**
|
||||
* A code span holding nothing but one ref (`` `[file:12]` ``): bots often
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BotQuestionRefHrefPrefix } from "./bot-question-ref";
|
||||
import { ChatroomRefHrefPrefix } from "./chatroom-ref";
|
||||
import { GraphicRefHrefPrefix } from "./graphic-ref";
|
||||
import {
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
||||
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
|
||||
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
|
||||
* request ref (`[secret-request:4]`), graphic ref (`[graphic:5]`) and
|
||||
* request ref (`[secret-request:4]`), question ref (`[question:4]`), graphic ref (`[graphic:5]`) and
|
||||
* chatroom ref (`[room:<id>]`) in a
|
||||
* stretch of message text (never code) into a markdown link, so it
|
||||
* survives markdown rendering as a link the chat can swap for a button,
|
||||
@@ -57,6 +58,10 @@ export default function linkRefsInText({ text }: { text: string }) {
|
||||
/\[secret-request:(\d+)\](?!\()/g,
|
||||
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
|
||||
)
|
||||
.replace(
|
||||
/\[question:(\d+)\](?!\()/g,
|
||||
(_, id: string) => `[questions ${id}](${BotQuestionRefHrefPrefix}${id})`,
|
||||
)
|
||||
.replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`)
|
||||
.replace(/\[room:(\d+)\](?!\()/g, (_, id: string) => `[room ${id}](${ChatroomRefHrefPrefix}${id})`)
|
||||
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseBotQuestionRow from "./parse-bot-question-row";
|
||||
|
||||
const questions = [{ question: "Which?", kind: "single", options: ["A", "B"], allow_other: false }];
|
||||
|
||||
describe("parseBotQuestionRow", () => {
|
||||
test("reads questions and answers", () => {
|
||||
expect(
|
||||
parseBotQuestionRow({
|
||||
row: { questions: JSON.stringify(questions), answers: JSON.stringify([{ choices: [1], other: "" }]) },
|
||||
}),
|
||||
).toEqual({ questions: questions as never, answers: [{ choices: [1], other: "" }] });
|
||||
});
|
||||
|
||||
test("has no answers until answered, or when they don't match", () => {
|
||||
expect(parseBotQuestionRow({ row: { questions: JSON.stringify(questions), answers: null } }).answers).toBeNull();
|
||||
expect(parseBotQuestionRow({ row: { questions: JSON.stringify(questions), answers: "[]" } }).answers).toBeNull();
|
||||
});
|
||||
|
||||
test("survives unreadable JSON", () => {
|
||||
expect(parseBotQuestionRow({ row: { questions: "{", answers: "{" } })).toEqual({ questions: [], answers: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
row: { questions?: string | null; answers?: string | null };
|
||||
};
|
||||
|
||||
/**
|
||||
* A `bot_questions` row's stored JSON read back: its questions (none if
|
||||
* unreadable) and its answers (null until answered, or when they don't
|
||||
* match the questions).
|
||||
*/
|
||||
export default function parseBotQuestionRow({ row }: Params): {
|
||||
questions: BotQuestionItem[];
|
||||
answers: BotQuestionAnswer[] | null;
|
||||
} {
|
||||
const questions = parseJson<BotQuestionItem[]>({ text: row.questions });
|
||||
const answers = parseJson<BotQuestionAnswer[]>({ text: row.answers });
|
||||
const final_questions = Array.isArray(questions) ? questions : [];
|
||||
|
||||
return {
|
||||
questions: final_questions,
|
||||
answers: Array.isArray(answers) && answers.length == final_questions.length ? answers : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson<T>({ text }: { text?: string | null }): T | null {
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user