Give every user their own bot face

A bot's face and status line live in bot_user_faces, one row per bot and
user, set by jobs for the user whose conversation they're in. Needs-you
and working only show to that user; every bot read overlays the viewer's
face (botFaceSql) and pushes go to the user a face change is for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 13:58:26 +01:00
co-authored by Claude Opus 5.5
parent 47c58bc76b
commit ab25158d87
32 changed files with 448 additions and 181 deletions
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabChatBot from "../bots/grab-chat-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
@@ -13,7 +14,10 @@ type Params = {
};
export type UserConversation = {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
/**
* With the user's own face.
*/
bot: BotWithFace & { id: number };
workspace: BUN_SQLITE_COBBLE_WORKSPACES | null;
workspace_id: number | null;
};
@@ -1,11 +1,13 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
type Params = {
job_id: number | string;
@@ -27,9 +29,9 @@ type Params = {
/**
* Marks a job done, closes its task and sends the completion message back
* to the user in the bot's conversation. Unless it has other jobs open,
* the bot shows needs-you if asked, else done after a task and idle after
* a reply. A handed-over job's result also goes to the bot that handed
* to the user in the bot's conversation. Unless it has other jobs open
* for that user, the bot shows that user needs-you if asked, else done
* after a task and idle after a reply; nobody else's face changes. A handed-over job's result also goes to the bot that handed
* it over (`relay`). Throws if this handler no longer holds the job.
*/
export default async function completeBotJob({
@@ -78,18 +80,24 @@ export default async function completeBotJob({
needs_you,
});
const bot = needs_you
? await setBotFace({
bot_id: job.bot_id,
face: "needs-you",
status_line: "Waiting on you",
})
: (await countOpenBotJobs({ bot_id: job.bot_id }))
? null
: await setBotFace({
const user_id = await grabJobUserId({ job });
const bot = !user_id
? null
: needs_you
? await setBotFace({
bot_id: job.bot_id,
face: job.kind == "reply" ? "idle" : "done",
});
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: (await countOpenBotJobs({ bot_id: job.bot_id, user_id }))
? null
: await setBotFace({
bot_id: job.bot_id,
user_id,
face: job.kind == "reply" ? "idle" : "done",
});
const relay = silent ? null : await relayJobToRequester({ job, text: result });
@@ -2,15 +2,20 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
/**
* Only jobs in this user's conversations.
*/
user_id: number | string;
};
/**
* How many of a bot's jobs are still pending or processing.
* How many of a bot's jobs for one user are still pending or processing.
*/
export default async function countOpenBotJobs({ bot_id }: Params) {
export default async function countOpenBotJobs({ bot_id, user_id }: Params) {
const res = await BunSQLite.sql<{ open: number }>({
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')`,
values: [bot_id],
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')
AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)`,
values: [bot_id, user_id],
});
return Number(res.payload?.[0]?.open || 0);
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import jobSessionId from "./job-session-id";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "session_id">;
};
/**
* The user a job works for: the one whose conversation it's in. Null once
* that conversation is gone.
*/
export default async function grabJobUserId({ job }: Params) {
const user_id = (
await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [jobSessionId({ job })],
})
).payload?.[0]?.user_id;
return user_id ? Number(user_id) : null;
}
@@ -4,6 +4,8 @@ import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -13,7 +15,7 @@ type Params = {
/**
* Wraps up a job that is out of attempts: stamps `finished_at` (so it's
* reported once), tells the user in the bot's conversation and sets the
* bot to needs-you. Its task stays active, since the work isn't done. A
* bot to needs-you for that user. Its task stays active, since the work isn't done. A
* handed-over job's failure also goes to the bot that handed it over.
*/
export default async function notifyBotJobFailed({
@@ -46,11 +48,16 @@ export default async function notifyBotJobFailed({
needs_you: true,
});
const bot = await setBotFace({
bot_id: job.bot_id,
face: "needs-you",
status_line: "Waiting on you",
});
const user_id = await grabJobUserId({ job });
const bot = user_id
? await setBotFace({
bot_id: job.bot_id,
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: null;
const relay = await relayJobToRequester({
job,
@@ -42,8 +42,6 @@ export default async function createBot({ user_id, name, role, hue, system_promp
kind: "worker",
hue,
role: role || undefined,
face: "idle",
status_line: "",
system_prompt,
},
],
@@ -55,8 +55,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
user_id: Number(user_id),
name: CoordinatorBotName,
kind: "coordinator",
face: "idle",
status_line: "",
system_prompt: CoordinatorSystemPrompt,
},
],
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot's face and status line as one user sees it: idle when it has
* none for them.
*/
export default async function grabBotFace({ bot_id, user_id }: Params) {
const row = (
await BunSQLite.sql<{ face: BotFace; status_line: string }>({
sql: `SELECT face, status_line FROM bot_user_faces WHERE bot_id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
})
).payload?.[0];
return { face: (row?.face || "idle") as BotFace, status_line: row?.status_line || "" };
}
+7 -3
View File
@@ -2,6 +2,7 @@ import type { BotWithLastMessage } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -18,8 +19,8 @@ type Params = {
* The bots a user chats with, for the rail: in their own chat
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
* coordinator is pinned first, then workers by latest activity. Each row
* carries the user's own conversation there: its unread flag, latest
* activity and latest message.
* carries the user's own face (`botFaceSql`) and conversation there: its
* unread flag, latest activity and latest message.
*/
export default async function grabBots({
user_id,
@@ -29,10 +30,13 @@ export default async function grabBots({
const role = await grabCrewRole({ user_id });
const access = scope ? workspaceBotsSql({ role, workspace_id: scope }) : chatBotsSql({ role, user_id });
const face = botFaceSql({ user_id });
let sql = ``;
sql += `SELECT\n`;
sql += ` b.*,\n`;
sql += ` ${face.sql},\n`;
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
sql += ` t.last_activity_at AS last_activity_at,\n`;
sql += ` lm.text AS last_message_text,\n`;
@@ -55,7 +59,7 @@ export default async function grabBots({
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [scope, user_id, scope, user_id, ...access.values],
values: [...face.values, scope, user_id, scope, user_id, ...access.values],
});
return res.payload || [];
+10 -6
View File
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -9,17 +10,20 @@ type Params = {
};
/**
* A bot only if the user chats with it in its own chat (`chatBotsSql`).
* A bot, with the user's own face (`botFaceSql`), only if the user chats
* with it in its own chat (`chatBotsSql`).
*/
export default async function grabChatBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
}: Params): Promise<BotWithFace | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [bot_id, ...access.values],
const face = botFaceSql({ user_id });
const res = await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [...face.values, bot_id, ...access.values],
});
return res.payload?.[0] || null;
+12 -8
View File
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -9,23 +10,26 @@ type Params = {
};
/**
* A bot only if the user reaches it somewhere: in its own chat
* (`chatBotsSql`) or in a workspace they're in. Super admins and admins
* reach every worker; only the super admin reaches the coordinator.
* A bot, with the user's own face (`botFaceSql`), only if the user
* reaches it somewhere: in its own chat (`chatBotsSql`) or in a workspace
* they're in. Super admins and admins reach every worker; only the super
* admin reaches the coordinator.
*/
export default async function grabUserBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
}: Params): Promise<BotWithFace | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND (${access.sql} OR (b.kind != 'coordinator' AND b.id IN (
const face = botFaceSql({ user_id });
const res = await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND (${access.sql} OR (b.kind != 'coordinator' AND b.id IN (
SELECT wb.bot_id FROM workspace_bots wb
JOIN workspace_users wu ON wu.workspace_id = wb.workspace_id AND wu.user_id = ?
JOIN workspaces w ON w.id = wb.workspace_id AND w.archived = 0
))) LIMIT 1`,
values: [bot_id, ...access.values, user_id],
values: [...face.values, bot_id, ...access.values, user_id],
});
return res.payload?.[0] || null;
+22 -5
View File
@@ -3,23 +3,40 @@ import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
/**
* The user who sees it: the one whose conversation the job is in.
*/
user_id: number | string;
face: BotFace;
status_line?: string;
};
export type BotFaceChange = {
id: number;
user_id: number;
face: BotFace;
status_line: string;
};
/**
* Sets a bot's face (its status) and status line. Callers must check the
* Sets a bot's face (its status) and status line as one user sees it
* (`bot_user_faces`). Other users keep their own. Callers must check the
* bot first.
*/
export default async function setBotFace({
bot_id,
user_id,
face,
status_line = "",
}: Params) {
}: Params): Promise<BotFaceChange> {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bots SET face = ?, status_line = ?, updated_at = ? WHERE id = ?`,
values: [face, status_line, Date.now(), bot_id],
sql: `INSERT INTO bot_user_faces (created_at, updated_at, bot_id, user_id, face, status_line)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET face = excluded.face, status_line = excluded.status_line, updated_at = excluded.updated_at`,
values: [now, now, bot_id, user_id, face, status_line],
});
return { id: Number(bot_id), face, status_line };
return { id: Number(bot_id), user_id: Number(user_id), face, status_line };
}
@@ -1,15 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotWithFace } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
type Params = {
computer_id: number | string;
/**
* Whose faces the bots show.
*/
user_id: number | string;
};
export type ComputerAccess = {
bots: BUN_SQLITE_COBBLE_BOTS[];
bots: BotWithFace[];
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
@@ -20,14 +25,17 @@ export type ComputerAccess = {
*/
export default async function grabComputerAccess({
computer_id,
user_id,
}: Params): Promise<ComputerAccess> {
const face = botFaceSql({ user_id });
const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b
BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b
JOIN bot_computers bc ON bc.bot_id = b.id
WHERE bc.computer_id = ?
ORDER BY b.kind = 'coordinator' DESC, b.name COLLATE NOCASE ASC, b.id ASC`,
values: [computer_id],
values: [...face.values, computer_id],
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT w.* FROM workspaces w
@@ -1,5 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { CronJobWithNames } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
type Params = {
user_id: number | string;
@@ -14,10 +15,12 @@ type Params = {
};
/**
* The user's schedules with their bot and workspace names, the next to
* The user's schedules with their bot (its face as the user sees it) and
* workspace names, the next to
* run first and paused ones last.
*/
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const where = [`c.user_id = ?`];
const values: (number | string)[] = [user_id];
@@ -32,14 +35,14 @@ export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Par
}
const res = await BunSQLite.sql<CronJobWithNames>({
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.face AS bot_face,
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, ${face.sql},
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
FROM cron_jobs c
JOIN bots b ON b.id = c.bot_id
LEFT JOIN workspaces w ON w.id = c.workspace_id
WHERE ${where.join(" AND ")}
ORDER BY c.enabled DESC, c.next_run_at IS NULL, c.next_run_at ASC, c.id ASC`,
values,
values: [...face.values, ...values],
});
return res.payload || [];
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { WorkspaceActivityItem } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
type Params = {
user_id: number | string;
@@ -9,19 +10,21 @@ type Params = {
/**
* The user's latest conversations in a workspace, most recent first, each
* with the bot it's with, its message count and latest message. Empty
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty
* sessions are left out unless active.
*/
export default async function grabWorkspaceActivity({
user_id,
workspace_id,
}: Params): Promise<WorkspaceActivityItem[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const res = await BunSQLite.sql<WorkspaceActivityItem>({
sql: `SELECT s.*,
b.name AS bot_name,
b.image_media_id AS bot_image_media_id,
b.hue AS bot_hue,
b.face AS bot_face,
${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text
FROM bot_sessions s
@@ -33,7 +36,7 @@ export default async function grabWorkspaceActivity({
))
ORDER BY COALESCE(s.last_activity_at, s.created_at) DESC, s.id DESC
LIMIT ?`,
values: [workspace_id, user_id, AppData.WorkspaceActivityItems],
values: [...face.values, workspace_id, user_id, AppData.WorkspaceActivityItems],
});
return res.payload || [];
@@ -1,11 +1,12 @@
import type { BotWithFace } from "@/src/types";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -16,7 +17,10 @@ type Params = {
export type WorkspaceBotMembership = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
bot: BUN_SQLITE_COBBLE_BOTS;
/**
* With the user's own face (`botFaceSql`).
*/
bot: BotWithFace;
/**
* Its `workspace_bots` row. Null for the coordinator, which works in
* every workspace without one until it's given instructions there.
@@ -47,11 +51,12 @@ export default async function grabWorkspaceBot({
if (!workspace?.id) return null;
const bot_access = workspaceBotsSql({ role, workspace_id: workspace.id });
const face = botFaceSql({ user_id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [bot_id, ...bot_access.values],
await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [...face.values, bot_id, ...bot_access.values],
})
).payload?.[0];