Each project (space) now has a main agent and a main conversation of its own. The user talks to it, and it delegates to the project's specialists. Creating a project sets both up and opens the conversation, and deleting an otherwise empty project removes them with it. Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur Co-authored-by: Claude <noreply@anthropic.com>
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
import { ORPCError } from "@orpc/server";
|
|
import type { Actor, MainConversationRef } from "@rakazo/contracts";
|
|
import { hasMentionToken } from "@rakazo/core";
|
|
import {
|
|
ensureMainConversation,
|
|
findMainBotId,
|
|
joinMainConversation,
|
|
type PrismaClient,
|
|
} from "@rakazo/db";
|
|
|
|
/**
|
|
* Where the main conversation lives, repaired or created on the way. Null until onboarding has
|
|
* created the main bot: reading navigation must never create a bot by itself.
|
|
*/
|
|
export async function loadMainConversationRef(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
): Promise<MainConversationRef | null> {
|
|
if (!(await findMainBotId(prisma, userId))) return null;
|
|
const conversation = await ensureMainConversation(prisma, userId);
|
|
return {
|
|
botId: conversation.leadBotId,
|
|
groupId: conversation.groupId,
|
|
spaceId: conversation.spaceId,
|
|
};
|
|
}
|
|
|
|
export function assertNotMainBot(bot: { kind: string }, message: string) {
|
|
if (bot.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
|
}
|
|
|
|
/** Unknown or foreign groups fall through so the caller's own lookup reports them as before. */
|
|
export async function assertNotMainConversation(
|
|
prisma: PrismaClient,
|
|
actor: Pick<Actor, "spaceId" | "userId">,
|
|
groupId: string,
|
|
message: string,
|
|
) {
|
|
const group = await prisma.chatGroup.findFirst({
|
|
where: { id: groupId, spaceId: actor.spaceId, userId: actor.userId },
|
|
select: { kind: true },
|
|
});
|
|
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
|
}
|
|
|
|
/** A main conversation carries its main bot's name. */
|
|
export async function renameMainConversation(
|
|
prisma: PrismaClient,
|
|
main: { userId: string; spaceId: string },
|
|
name: string,
|
|
) {
|
|
await prisma.chatGroup.updateMany({
|
|
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
|
|
data: { name },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
|
|
* join it first, so the member mention rules route the message to them.
|
|
*/
|
|
export async function joinAddressedBots(
|
|
prisma: PrismaClient,
|
|
actor: Pick<Actor, "spaceId" | "userId">,
|
|
input: { memberBotIds: string[]; text: string; botMentionIds: string[] },
|
|
) {
|
|
const candidates = await prisma.bot.findMany({
|
|
where: {
|
|
userId: actor.userId,
|
|
spaceId: actor.spaceId,
|
|
archivedAt: null,
|
|
id: { notIn: input.memberBotIds },
|
|
},
|
|
select: { id: true, name: true },
|
|
});
|
|
const addressed = candidates
|
|
.filter((bot) => input.botMentionIds.includes(bot.id) || hasMentionToken(input.text, bot.name))
|
|
.map((bot) => bot.id);
|
|
if (addressed.length === 0) return;
|
|
// Subscribers pick up the group.updated event with the send that follows.
|
|
await joinMainConversation(prisma, {
|
|
userId: actor.userId,
|
|
spaceId: actor.spaceId,
|
|
botIds: addressed,
|
|
});
|
|
}
|