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:
2026-10-28 11:10:23 +01:00
co-authored by Claude Opus 5.5
parent 07f87ce4ea
commit d7dfe0e78e
45 changed files with 1448 additions and 6 deletions
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
answers: { choices: number[]; other?: string }[];
};
/**
* The user answered questions a bot asked in its card: checked against the
* questions (`checkBotQuestionAnswers`), then stored once (only pending
* questions move, so a double click answers once).
*/
export default async function answerBotQuestion({ question_id, user_id, answers }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const { questions } = parseBotQuestionRow({ row });
const checked = checkBotQuestionAnswers({ questions, answers });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'answered', answers = ?, decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [JSON.stringify(checked), now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS, BunSQLiteTables } from "@/db/types/db";
import type { BotQuestionItem } from "@/src/types";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id: number | string;
workspace_id?: number | string | null;
title: string;
questions: BotQuestionItem[];
};
/**
* Stores questions a bot asks the user, pending until they answer or skip
* them in its card. Returns its id.
*/
export default async function createBotQuestion({ user_id, bot_id, session_id, workspace_id, title, questions }: Params) {
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_QUESTIONS, (typeof BunSQLiteTables)[number]>({
table: "bot_questions",
data: [
{
title,
questions: JSON.stringify(questions),
status: "pending",
user_id: Number(user_id),
bot_id: Number(bot_id),
session_id: Number(session_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
},
],
});
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
if (!res.success || !id) throw new Error(`Couldn't store the questions: ${res.msg}`);
return id;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotQuestionStatus } from "@/src/dict/bot-question-dict";
import type { BotQuestionView } from "@/src/types";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* Questions a bot asked the user as their chip and card show them: the
* questions, their options and, once answered, what the user picked.
*/
export default async function grabBotQuestionView({ question_id, user_id }: Params): Promise<BotQuestionView> {
const row = await grabBotQuestion({ question_id, user_id });
const { questions, answers } = parseBotQuestionRow({ row });
const bot = (
await BunSQLite.sql<{ name: string }>({
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
values: [row.bot_id || 0],
})
).payload?.[0];
return {
id: Number(row.id),
title: row.title || "",
questions,
answers,
status: (row.status || "pending") as BotQuestionStatus,
bot_id: Number(row.bot_id),
bot_name: bot?.name || "The bot",
};
}
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
question_id: number | string;
/**
* Only the user they were asked of reads them.
*/
user_id: number | string;
};
/**
* Questions a bot asked, only for the user they were asked of. Throws
* otherwise.
*/
export default async function grabBotQuestion({ question_id, user_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_QUESTIONS>({
sql: `SELECT * FROM bot_questions WHERE id = ? AND user_id = ? LIMIT 1`,
values: [Number(question_id), Number(user_id)],
})
).payload?.[0];
if (!row?.id) throw new AppError(`Those questions aren't yours or no longer exist.`);
return row;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* The user skipped questions a bot asked: nothing is answered.
*/
export default async function skipBotQuestion({ question_id, user_id }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'skipped', decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -55,6 +55,7 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_questions WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
@@ -74,7 +74,7 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
/**
* These point at the user without a foreign key, so nothing cascades.
*/
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_graphics"]) {
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_questions", "bot_graphics"]) {
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}