A workspace conversation is never remembered, so /admin/bots always reopens the bot's own chat.
This commit is contained in:
@@ -98,7 +98,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
- **Tables:** `bots` (`kind` worker|coordinator, `color` (a free `#rrggbb` the user picks) and `stone` (which flat-cut shape it's drawn on), `role`, `system_prompt` (the bot's core instructions, markdown); `hue` is the palette column they replaced, read only as a fallback), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
|
||||
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
|
||||
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
|
||||
- **Routes:** `/admin/bots` opens the conversation this browser was last in (`readLastVisitedBot` from the `cobble_last_visited_bot_id` / `..._workspace_id` cookies, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
|
||||
- **Routes:** `/admin/bots` opens the bot this browser was last in (`readLastVisitedBot` from the single `cobble_last_visited_bot_id` cookie, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears; a conversation inside a workspace is never remembered, so the landing page always reopens the bot's own chat), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
|
||||
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; The page renders a session's latest `AppData.ConversationFirstMessages` messages (`bot_messages_more` says there are older), and the thread loads older ones `AppData.ConversationOlderMessages` at a time as it scrolls to its top (`older-messages.ts`, `use-older-messages`, `older-messages-loader.tsx`). Bot and workspace lists in page props go through `railBots` / `railWorkspaces` (`src/utils/`): no core instructions, memory or workspace context, and the last message cut to a preview (`AppData.RailPreviewMaxChars`); the header avatar's edit form loads the instructions when it opens (`bot-instructions.ts`, `use-bot-instructions`). `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
|
||||
- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Sending shows the bubble at once and empties the input (with its quote and files), so a slow connection never waits on the request: the optimistic message rides in `pending_messages` in `conversation.tsx` until the response or a push replaces it with the saved one (`(utils)/drop-sent-message.ts`), and a failed send takes it back and restores the text and files. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`).
|
||||
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed).
|
||||
|
||||
@@ -13,9 +13,6 @@ describe("AppData auth constants", () => {
|
||||
expect(AppData["LastVisitedBotCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_last_visited_bot_id`,
|
||||
);
|
||||
expect(AppData["LastVisitedWorkspaceCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_last_visited_workspace_id`,
|
||||
);
|
||||
});
|
||||
|
||||
test("expiry and limits are positive", () => {
|
||||
|
||||
@@ -55,14 +55,12 @@ export const AppData = {
|
||||
BuildCookieName: `${SiteData["SiteSlug"]}_build`,
|
||||
BuildCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
/**
|
||||
* The conversation this browser was last in: `/admin/bots` opens it
|
||||
* The bot this browser was last in: `/admin/bots` opens its own chat
|
||||
* again, falling back to the coordinator (super admin) or the latest
|
||||
* bot when the grant is gone. Two small ids, so the workspace one
|
||||
* stays empty for a bot's own chat. `HttpOnly`: only the server reads
|
||||
* them.
|
||||
* bot when the grant is gone. One small id. `HttpOnly`: only the server
|
||||
* reads it.
|
||||
*/
|
||||
LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`,
|
||||
// LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`,
|
||||
LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
AuthExpiryDays: 60,
|
||||
APICrudBasePath: "/api/admin/crud",
|
||||
|
||||
@@ -3,38 +3,30 @@ import grabUserConversation from "./grab-user-conversation";
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
export type ReachableConversation = {
|
||||
bot_id: number;
|
||||
workspace_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation from the last-visited cookie, only while the user still
|
||||
* The bot's own chat from the last-visited cookie, only while the user still
|
||||
* reaches it: `grabUserConversation` is the same access check the
|
||||
* conversation pages use, so a revoked grant, a deleted bot or an archived
|
||||
* workspace answers null and the landing page falls back instead of
|
||||
* redirecting into a page that would bounce it back.
|
||||
* conversation pages use, so a revoked grant or a deleted bot answers null
|
||||
* and the landing page falls back instead of redirecting into a page that
|
||||
* would bounce it back.
|
||||
*/
|
||||
export default async function grabReachableConversation({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<ReachableConversation | null> {
|
||||
const resolved_workspace_id = workspace_id ? Number(workspace_id) : null;
|
||||
|
||||
const conversation = await grabUserConversation({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id: resolved_workspace_id,
|
||||
workspace_id: null,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!conversation?.bot.id) return null;
|
||||
|
||||
return { bot_id: conversation.bot.id, workspace_id: conversation.workspace_id };
|
||||
return { bot_id: conversation.bot.id };
|
||||
}
|
||||
|
||||
@@ -6,30 +6,20 @@ type Params = {
|
||||
req: Request;
|
||||
res: Response;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page's response with the last-visited cookies set, so `/admin/bots`
|
||||
* opens this conversation again next time. Called from the conversation
|
||||
* The page's response with the last-visited cookie set, so `/admin/bots`
|
||||
* opens this bot's own chat again next time. Called from the conversation
|
||||
* pages' `res_transform`, which is why it takes and returns the response.
|
||||
*/
|
||||
export default function withLastVisitedBotCookies({
|
||||
req,
|
||||
res,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Response {
|
||||
export default function withLastVisitedBotCookies({ req, res, bot_id }: Params): Response {
|
||||
const new_res = res.clone();
|
||||
|
||||
setCookies(
|
||||
new_res,
|
||||
lastVisitedBotCookies({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
secure: isSecureRequest({ req }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,8 +12,8 @@ import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
import readLastVisitedBot from "@/src/utils/read-last-visited-bot";
|
||||
|
||||
/**
|
||||
* Opens the conversation this browser was last in, from the last-visited
|
||||
* cookies the conversation pages set, while the user still reaches it.
|
||||
* Opens the bot's own chat this browser was last in, from the last-visited
|
||||
* cookie the conversation pages set, while the user still reaches it.
|
||||
* Otherwise the super admin lands on the coordinator, which (with the main
|
||||
* computer) is created first if the install has none; anyone else lands on
|
||||
* the latest bot they chat with, else their first workspace; with neither,
|
||||
@@ -62,7 +62,7 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The path of the conversation this browser was last in, if the user still
|
||||
* The path of the bot's own chat this browser was last in, if the user still
|
||||
* reaches it (`grabReachableConversation`, the conversation pages' own
|
||||
* access check). Null sends the page on to its usual fallback.
|
||||
*/
|
||||
@@ -74,12 +74,9 @@ async function grabLastVisited({ user_id, req }: { user_id: number | string; req
|
||||
const conversation = await grabReachableConversation({
|
||||
user_id,
|
||||
bot_id: visited.bot_id,
|
||||
workspace_id: visited.workspace_id,
|
||||
});
|
||||
|
||||
if (!conversation) return null;
|
||||
|
||||
return conversation.workspace_id
|
||||
? `/admin/workspaces/${conversation.workspace_id}/bots/${conversation.bot_id}`
|
||||
: `/admin/bots/${conversation.bot_id}`;
|
||||
return `/admin/bots/${conversation.bot_id}`;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
* A bot's conversation inside a workspace: the bot works with the
|
||||
* workspace's context, computers and models. Only the coordinator and the
|
||||
* workspace's bots have one; anyone else goes back to the workspace
|
||||
* settings (managers) or their bots. Rendering it records the conversation
|
||||
* in the last-visited cookies, so `/admin/bots` opens it again.
|
||||
* settings (managers) or their bots. Rendering it records the bot in the
|
||||
* last-visited cookie, so `/admin/bots` opens its own chat again.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
@@ -37,12 +37,11 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
}
|
||||
|
||||
const bot_id = props.bot.id;
|
||||
const workspace_id = props.workspace?.id ?? query.workspace_id;
|
||||
|
||||
return {
|
||||
props,
|
||||
res_transform(res) {
|
||||
return withLastVisitedBotCookies({ req: params.req, res, bot_id, workspace_id });
|
||||
return withLastVisitedBotCookies({ req: params.req, res, bot_id });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,10 +36,6 @@ export const server: BunextPageServerFn = async ({ req }) => {
|
||||
name: AppData["LastVisitedBotCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
{
|
||||
name: AppData["LastVisitedWorkspaceCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
]);
|
||||
return new_res;
|
||||
},
|
||||
|
||||
@@ -5,14 +5,10 @@ import { setCookies } from "./cookies";
|
||||
import readLastVisitedBot from "./read-last-visited-bot";
|
||||
|
||||
const bot_cookie = AppData["LastVisitedBotCookieName"];
|
||||
const workspace_cookie = AppData["LastVisitedWorkspaceCookieName"];
|
||||
|
||||
function requestWithCookies(): Request {
|
||||
const res = new Response();
|
||||
setCookies(
|
||||
res,
|
||||
lastVisitedBotCookies({ bot_id: 4, workspace_id: 9, secure: false }),
|
||||
);
|
||||
setCookies(res, lastVisitedBotCookies({ bot_id: 4, secure: false }));
|
||||
const cookie_header = res.headers
|
||||
.getSetCookie()
|
||||
.map((header) => header.split(";")[0])
|
||||
@@ -24,39 +20,23 @@ function requestWithCookies(): Request {
|
||||
}
|
||||
|
||||
describe("last visited bot cookies", () => {
|
||||
test("round-trip a workspace conversation", () => {
|
||||
expect(readLastVisitedBot({ req: requestWithCookies() })).toEqual({
|
||||
bot_id: 4,
|
||||
workspace_id: 9,
|
||||
});
|
||||
test("round-trip a bot", () => {
|
||||
expect(readLastVisitedBot({ req: requestWithCookies() })).toEqual({ bot_id: 4 });
|
||||
});
|
||||
|
||||
test("a bot's own chat leaves the workspace cookie empty", () => {
|
||||
test("only the bot cookie is written, never a workspace one", () => {
|
||||
const res = new Response();
|
||||
setCookies(res, lastVisitedBotCookies({ bot_id: 4, secure: false }));
|
||||
const cookie_header = res.headers
|
||||
.getSetCookie()
|
||||
.map((header) => header.split(";")[0])
|
||||
.join("; ");
|
||||
const [header, ...others] = res.headers.getSetCookie();
|
||||
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: cookie_header },
|
||||
});
|
||||
|
||||
expect(readLastVisitedBot({ req })).toEqual({ bot_id: 4, workspace_id: null });
|
||||
expect(others).toHaveLength(0);
|
||||
expect(header?.startsWith(`${bot_cookie}=4`)).toBe(true);
|
||||
});
|
||||
|
||||
test("no cookies at all", () => {
|
||||
expect(readLastVisitedBot({ req: new Request("http://localhost") })).toBeNull();
|
||||
});
|
||||
|
||||
test("only the workspace cookie names no conversation", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: `${workspace_cookie}=9` },
|
||||
});
|
||||
expect(readLastVisitedBot({ req })).toBeNull();
|
||||
});
|
||||
|
||||
test("a tampered bot id is ignored", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: `${bot_cookie}=1%20OR%201=1` },
|
||||
|
||||
@@ -4,26 +4,19 @@ import type { CookieOptions } from "./cookies";
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* From `isSecureRequest`, so plain HTTP over a VPN still keeps them.
|
||||
* From `isSecureRequest`, so plain HTTP over a VPN still keeps it.
|
||||
*/
|
||||
secure: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The cookies that remember the conversation this browser is in, for
|
||||
* `setCookies`: read back by `readLastVisitedBot` on the bots' landing page.
|
||||
* `HttpOnly`, since only the server reads them, and they hold nothing but
|
||||
* two ids.
|
||||
* The cookie that remembers the bot this browser is in, for `setCookies`:
|
||||
* read back by `readLastVisitedBot` on the bots' landing page. `HttpOnly`,
|
||||
* since only the server reads it, and it holds nothing but an id. A
|
||||
* conversation inside a workspace is never remembered, so the landing page
|
||||
* always reopens the bot's own chat.
|
||||
*/
|
||||
export default function lastVisitedBotCookies({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
secure,
|
||||
}: Params): CookieOptions[] {
|
||||
export default function lastVisitedBotCookies({ bot_id, secure }: Params): CookieOptions[] {
|
||||
return [
|
||||
{
|
||||
name: AppData["LastVisitedBotCookieName"],
|
||||
@@ -31,11 +24,5 @@ export default function lastVisitedBotCookies({
|
||||
secure,
|
||||
maxAge: AppData["LastVisitedCookieMaxAgeSeconds"],
|
||||
},
|
||||
// {
|
||||
// name: AppData["LastVisitedWorkspaceCookieName"],
|
||||
// value: workspace_id ? String(workspace_id) : "",
|
||||
// secure,
|
||||
// maxAge: AppData["LastVisitedCookieMaxAgeSeconds"],
|
||||
// },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,27 +2,14 @@ import { describe, expect, test } from "bun:test";
|
||||
import parseLastVisitedBot from "./parse-last-visited-bot";
|
||||
|
||||
describe("parseLastVisitedBot", () => {
|
||||
test("a bot's own chat", () => {
|
||||
expect(parseLastVisitedBot({ bot_id: "3" })).toEqual({ bot_id: 3, workspace_id: null });
|
||||
expect(parseLastVisitedBot({ bot_id: 3, workspace_id: "" })).toEqual({
|
||||
bot_id: 3,
|
||||
workspace_id: null,
|
||||
});
|
||||
expect(parseLastVisitedBot({ bot_id: 3, workspace_id: null })).toEqual({
|
||||
bot_id: 3,
|
||||
workspace_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("a workspace conversation", () => {
|
||||
expect(parseLastVisitedBot({ bot_id: "3", workspace_id: "7" })).toEqual({
|
||||
bot_id: 3,
|
||||
workspace_id: 7,
|
||||
});
|
||||
test("a bot id becomes the bot", () => {
|
||||
expect(parseLastVisitedBot({ bot_id: "3" })).toEqual({ bot_id: 3 });
|
||||
expect(parseLastVisitedBot({ bot_id: 3 })).toEqual({ bot_id: 3 });
|
||||
});
|
||||
|
||||
test("rejects ids that aren't positive whole numbers", () => {
|
||||
expect(parseLastVisitedBot({})).toBeNull();
|
||||
expect(parseLastVisitedBot({ bot_id: null })).toBeNull();
|
||||
expect(parseLastVisitedBot({ bot_id: "" })).toBeNull();
|
||||
expect(parseLastVisitedBot({ bot_id: "0" })).toBeNull();
|
||||
expect(parseLastVisitedBot({ bot_id: "-1" })).toBeNull();
|
||||
@@ -32,15 +19,7 @@ describe("parseLastVisitedBot", () => {
|
||||
expect(parseLastVisitedBot({ bot_id: "99999999999999999999" })).toBeNull();
|
||||
});
|
||||
|
||||
test("a bad workspace falls back to nothing, never to its own chat", () => {
|
||||
expect(parseLastVisitedBot({ bot_id: "3", workspace_id: "abc" })).toBeNull();
|
||||
expect(parseLastVisitedBot({ bot_id: "3", workspace_id: "0" })).toBeNull();
|
||||
});
|
||||
|
||||
test("trims what a cookie header may carry", () => {
|
||||
expect(parseLastVisitedBot({ bot_id: " 3 ", workspace_id: " 7 " })).toEqual({
|
||||
bot_id: 3,
|
||||
workspace_id: 7,
|
||||
});
|
||||
expect(parseLastVisitedBot({ bot_id: " 3 " })).toEqual({ bot_id: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
export type LastVisitedBot = {
|
||||
bot_id: number;
|
||||
/**
|
||||
* Null: the bot's own chat.
|
||||
*/
|
||||
workspace_id: number | null;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
bot_id?: string | number | null;
|
||||
workspace_id?: string | number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation the last-visited cookies name, or null when they don't
|
||||
* name one. Ids are checked to be positive whole numbers, so a hand-edited
|
||||
* cookie never reaches a query; whether the user still reaches that
|
||||
* conversation is the caller's check.
|
||||
* The bot the last-visited cookie names, or null when it doesn't name one.
|
||||
* The id is checked to be a positive whole number, so a hand-edited cookie
|
||||
* never reaches a query; whether the user still reaches that bot's own chat
|
||||
* is the caller's check.
|
||||
*/
|
||||
export default function parseLastVisitedBot({ bot_id, workspace_id }: Params): LastVisitedBot | null {
|
||||
export default function parseLastVisitedBot({ bot_id }: Params): LastVisitedBot | null {
|
||||
const bot = parseId(bot_id);
|
||||
|
||||
if (bot == null) return null;
|
||||
|
||||
if (workspace_id === undefined || workspace_id === null || workspace_id === "") {
|
||||
return { bot_id: bot, workspace_id: null };
|
||||
}
|
||||
|
||||
const workspace = parseId(workspace_id);
|
||||
|
||||
if (workspace == null) return null;
|
||||
|
||||
return { bot_id: bot, workspace_id: workspace };
|
||||
return { bot_id: bot };
|
||||
}
|
||||
|
||||
function parseId(value?: string | number | null): number | null {
|
||||
|
||||
@@ -7,14 +7,13 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation this browser was last in, from the last-visited cookies
|
||||
* (see `lastVisitedBotCookies`). Null when it has none, or when they don't
|
||||
* name a conversation. Whether the user still reaches it is the caller's
|
||||
* The bot this browser was last in, from the last-visited cookie (see
|
||||
* `lastVisitedBotCookies`). Null when it has none, or when it doesn't name
|
||||
* a bot. Whether the user still reaches that bot's own chat is the caller's
|
||||
* check: a stale grant must fall back, never redirect.
|
||||
*/
|
||||
export default function readLastVisitedBot({ req }: Params): LastVisitedBot | null {
|
||||
return parseLastVisitedBot({
|
||||
bot_id: getCookie(req, AppData["LastVisitedBotCookieName"]),
|
||||
workspace_id: getCookie(req, AppData["LastVisitedWorkspaceCookieName"]),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user