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