Bots can offer an exact tool call as a card. Nothing runs until the user taps Accept; the call then runs as its own approved run of the bot it was meant for, and the card shows the outcome. Open suggestions also wait in the inbox and expire after a week. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
|
import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts";
|
|
import { isApprovalAskBlock } from "@rakazo/core";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { promptSnippet } from "./runs.js";
|
|
|
|
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
|
|
const INBOX_RUN_LIMIT = 50;
|
|
const INBOX_UNREAD_LIMIT = 50;
|
|
const INBOX_PREVIEW_LENGTH = 280;
|
|
|
|
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
|
type CardBlock = Extract<MessageBlock, { kind: "card" }>;
|
|
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
|
type InboxBot = { id: string; name: string; color: string };
|
|
type InboxSpace = NonNullable<InboxItem["space"]>;
|
|
|
|
export interface InboxRunRow {
|
|
id: string;
|
|
spaceId: string;
|
|
status: string;
|
|
updatedAt: Date;
|
|
bot: InboxBot;
|
|
thread: {
|
|
botId: string | null;
|
|
groupId: string | null;
|
|
group: { kind: string; archivedAt: Date | null } | null;
|
|
};
|
|
}
|
|
|
|
export interface InboxMessageRow {
|
|
id: string;
|
|
runId: string | null;
|
|
blocks: unknown;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface InboxUnreadThreadRow {
|
|
spaceId: string;
|
|
createdAt: Date;
|
|
bot: InboxBot | null;
|
|
/** The newest bot message, if any. */
|
|
messages: Array<Omit<InboxMessageRow, "runId">>;
|
|
}
|
|
|
|
/** An open suggestion: the card holding it and the thread it was posted in. */
|
|
export interface InboxSuggestionRow {
|
|
spaceId: string;
|
|
createdAt: Date;
|
|
bot: InboxBot;
|
|
thread: InboxRunRow["thread"];
|
|
messageId: string;
|
|
cardId: string;
|
|
/** The card message's blocks. */
|
|
blocks: unknown;
|
|
}
|
|
|
|
function parseBlocks(blocks: unknown): MessageBlock[] {
|
|
const parsed = MessageBlock.array().safeParse(blocks);
|
|
return parsed.success ? parsed.data : [];
|
|
}
|
|
|
|
function pendingAsk(blocks: MessageBlock[]): AskBlock | undefined {
|
|
return blocks.find(
|
|
(block): block is AskBlock => block.kind === "ask" && block.status !== "answered",
|
|
);
|
|
}
|
|
|
|
function takeoverRequest(blocks: MessageBlock[]): ComputerBlock | undefined {
|
|
return blocks.find((block): block is ComputerBlock => block.kind === "computer");
|
|
}
|
|
|
|
/** The newest message whose blocks hold what `pick` looks for. */
|
|
function newest<T>(messages: InboxMessageRow[], pick: (blocks: MessageBlock[]) => T | undefined) {
|
|
for (const message of messages) {
|
|
const block = pick(parseBlocks(message.blocks));
|
|
if (block) return { message, block };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function previewText(blocks: unknown): string {
|
|
for (const block of parseBlocks(blocks)) {
|
|
if ("text" in block && typeof block.text === "string" && block.text.trim()) {
|
|
return promptSnippet(block.text, INBOX_PREVIEW_LENGTH);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Where an ask can be answered from. The open space's main conversation already shows its own
|
|
* asks (a project's main conversation does not), and an archived group or an external
|
|
* conversation has no thread target to answer through.
|
|
*/
|
|
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
|
|
if (thread.groupId) {
|
|
if (!thread.group || thread.group.archivedAt) return null;
|
|
if (thread.group.kind === "main" && currentSpace) return null;
|
|
return { groupId: thread.groupId };
|
|
}
|
|
return thread.botId ? { botId: thread.botId } : null;
|
|
}
|
|
|
|
function targetKey(target: InboxThreadTarget): string {
|
|
return "botId" in target ? `bot:${target.botId}` : `group:${target.groupId}`;
|
|
}
|
|
|
|
function byCreatedAt(left: InboxItem, right: InboxItem): number {
|
|
return left.createdAt.localeCompare(right.createdAt);
|
|
}
|
|
|
|
/**
|
|
* Turns waiting runs, their ask and takeover messages, open suggestions and unread threads into
|
|
* inbox items:
|
|
* everything that waits on the user first, then unread threads not already covered, each
|
|
* oldest first. Items from another space name it.
|
|
*/
|
|
export function buildInboxItems(input: {
|
|
runs: InboxRunRow[];
|
|
/** Ask and takeover messages of those runs, newest first. */
|
|
messages: InboxMessageRow[];
|
|
unreadThreads: InboxUnreadThreadRow[];
|
|
/** Open suggestions, wherever their card was posted. */
|
|
suggestions?: InboxSuggestionRow[];
|
|
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
|
|
otherSpaces?: ReadonlyMap<string, InboxSpace>;
|
|
}): InboxItem[] {
|
|
const spaceOf = (spaceId: string) => {
|
|
const space = input.otherSpaces?.get(spaceId);
|
|
return space ? { space } : {};
|
|
};
|
|
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
|
for (const message of input.messages) {
|
|
if (!message.runId) continue;
|
|
const list = messagesByRun.get(message.runId) ?? [];
|
|
list.push(message);
|
|
messagesByRun.set(message.runId, list);
|
|
}
|
|
|
|
const waiting: InboxItem[] = [];
|
|
for (const run of input.runs) {
|
|
const messages = messagesByRun.get(run.id) ?? [];
|
|
if (run.status === "waiting_input") {
|
|
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
|
|
const pending = target ? newest(messages, pendingAsk) : undefined;
|
|
if (!target || !pending) continue;
|
|
waiting.push({
|
|
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
|
bot: run.bot,
|
|
target,
|
|
...spaceOf(run.spaceId),
|
|
runId: run.id,
|
|
messageId: pending.message.id,
|
|
ask: pending.block,
|
|
createdAt: pending.message.createdAt.toISOString(),
|
|
});
|
|
} else if (run.status === "waiting_takeover") {
|
|
// Takeover controls live in the bot's own thread, so the item stays even for a run that
|
|
// asked in the main conversation.
|
|
const request = newest(messages, takeoverRequest);
|
|
waiting.push({
|
|
kind: "takeover",
|
|
bot: run.bot,
|
|
target: { botId: run.bot.id },
|
|
...spaceOf(run.spaceId),
|
|
runId: run.id,
|
|
messageId: request?.message.id ?? null,
|
|
text: request?.block.text ?? "",
|
|
createdAt: (request?.message.createdAt ?? run.updatedAt).toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const suggestion of input.suggestions ?? []) {
|
|
const target = askTarget(suggestion.thread, !input.otherSpaces?.has(suggestion.spaceId));
|
|
const card = parseBlocks(suggestion.blocks).find(
|
|
(block): block is CardBlock => block.kind === "card" && block.id === suggestion.cardId,
|
|
);
|
|
if (!target || card?.suggestion?.status !== "proposed") continue;
|
|
waiting.push({
|
|
kind: "suggestion",
|
|
bot: suggestion.bot,
|
|
target,
|
|
...spaceOf(suggestion.spaceId),
|
|
messageId: suggestion.messageId,
|
|
card,
|
|
createdAt: suggestion.createdAt.toISOString(),
|
|
});
|
|
}
|
|
|
|
const covered = new Set(waiting.map((item) => targetKey(item.target)));
|
|
const unread: InboxItem[] = [];
|
|
for (const thread of input.unreadThreads) {
|
|
if (!thread.bot) continue;
|
|
const target = { botId: thread.bot.id };
|
|
if (covered.has(targetKey(target))) continue;
|
|
const latest = thread.messages[0];
|
|
unread.push({
|
|
kind: "unread",
|
|
bot: thread.bot,
|
|
target,
|
|
...spaceOf(thread.spaceId),
|
|
messageId: latest?.id ?? null,
|
|
text: previewText(latest?.blocks),
|
|
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
|
});
|
|
}
|
|
|
|
return [...waiting.sort(byCreatedAt), ...unread.sort(byCreatedAt)];
|
|
}
|
|
|
|
const botSelect = { id: true, name: true, color: true } as const;
|
|
|
|
/**
|
|
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
|
|
* being deleted.
|
|
*/
|
|
async function otherInboxSpaces(
|
|
prisma: PrismaClient,
|
|
actor: Actor,
|
|
): Promise<Map<string, InboxSpace>> {
|
|
const memberships = await prisma.spaceMember.findMany({
|
|
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
|
|
select: { space: { select: { id: true, name: true } } },
|
|
});
|
|
return new Map(memberships.map(({ space }) => [space.id, space]));
|
|
}
|
|
|
|
/**
|
|
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
|
|
* answered and opened in their own space, where the thread procedures check membership and
|
|
* ownership again.
|
|
*/
|
|
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
|
const otherSpaces = await otherInboxSpaces(prisma, actor);
|
|
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
|
|
const ownBot = { userId: actor.userId, archivedAt: null };
|
|
const threadSelect = {
|
|
botId: true,
|
|
groupId: true,
|
|
group: { select: { kind: true, archivedAt: true } },
|
|
} as const;
|
|
const [runs, unreadThreads, suggestionEffects] = await Promise.all([
|
|
prisma.run.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
userId: actor.userId,
|
|
status: { in: ["waiting_input", "waiting_takeover"] },
|
|
bot: ownBot,
|
|
thread: { userId: actor.userId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
spaceId: true,
|
|
status: true,
|
|
threadId: true,
|
|
updatedAt: true,
|
|
bot: { select: botSelect },
|
|
thread: { select: threadSelect },
|
|
},
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: INBOX_RUN_LIMIT,
|
|
}),
|
|
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
|
prisma.thread.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
userId: actor.userId,
|
|
unread: true,
|
|
bot: { ...ownBot, kind: "specialist" },
|
|
},
|
|
select: {
|
|
spaceId: true,
|
|
createdAt: true,
|
|
bot: { select: botSelect },
|
|
messages: {
|
|
where: { role: "bot" },
|
|
orderBy: { seq: "desc" },
|
|
take: 1,
|
|
select: { id: true, blocks: true, createdAt: true },
|
|
},
|
|
},
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
take: INBOX_UNREAD_LIMIT,
|
|
}),
|
|
prisma.externalEffect.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
kind: "suggestion",
|
|
status: "proposed",
|
|
createdAt: { gt: new Date(Date.now() - SUGGESTION_TTL_MS) },
|
|
run: { userId: actor.userId, bot: ownBot, thread: { userId: actor.userId } },
|
|
},
|
|
select: {
|
|
id: true,
|
|
spaceId: true,
|
|
createdAt: true,
|
|
request: true,
|
|
run: { select: { bot: { select: botSelect }, thread: { select: threadSelect } } },
|
|
},
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
take: INBOX_RUN_LIMIT,
|
|
}),
|
|
]);
|
|
const cardMessageIds = new Map<string, string>();
|
|
for (const effect of suggestionEffects) {
|
|
const messageId = (effect.request as { card?: { messageId?: unknown } } | null)?.card
|
|
?.messageId;
|
|
if (typeof messageId === "string") cardMessageIds.set(effect.id, messageId);
|
|
}
|
|
const cardMessages =
|
|
cardMessageIds.size === 0
|
|
? []
|
|
: await prisma.message.findMany({
|
|
where: { id: { in: [...cardMessageIds.values()] }, role: "bot" },
|
|
select: { id: true, blocks: true },
|
|
});
|
|
const blocksById = new Map(cardMessages.map((message) => [message.id, message.blocks]));
|
|
const suggestions: InboxSuggestionRow[] = suggestionEffects.flatMap((effect) => {
|
|
const messageId = cardMessageIds.get(effect.id);
|
|
if (!messageId || !blocksById.has(messageId)) return [];
|
|
return [
|
|
{
|
|
spaceId: effect.spaceId,
|
|
createdAt: effect.createdAt,
|
|
bot: effect.run.bot,
|
|
thread: effect.run.thread,
|
|
messageId,
|
|
cardId: effect.id,
|
|
blocks: blocksById.get(messageId),
|
|
},
|
|
];
|
|
});
|
|
const messages =
|
|
runs.length === 0
|
|
? []
|
|
: await prisma.message.findMany({
|
|
where: {
|
|
threadId: { in: [...new Set(runs.map((run) => run.threadId))] },
|
|
runId: { in: runs.map((run) => run.id) },
|
|
role: "bot",
|
|
OR: [
|
|
{ blocks: { array_contains: [{ kind: "ask" }] } },
|
|
{ blocks: { array_contains: [{ kind: "computer" }] } },
|
|
],
|
|
},
|
|
orderBy: { seq: "desc" },
|
|
select: { id: true, runId: true, blocks: true, createdAt: true },
|
|
});
|
|
return buildInboxItems({ runs, messages, unreadThreads, suggestions, otherSpaces });
|
|
}
|