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,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] });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user