Main now gives every project its own main agent. Coaching stays with the user's personal agent, the main bot of the home space: only it gets the goal, habit, check-in, brief and nudge tools, the coach summary and the open-onboarding note, and the tools refuse a project's main bot too. The onboarding questions start with the personal agent only. A project the scan creates gets its main agent like any other, and the inbox lists asks from another space's main conversation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
import type { NotificationProvider } from "@rakazo/adapter-kit";
|
|
import { NUDGES_PER_DAY } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import {
|
|
claimDailyNudge,
|
|
countPushSubscriptions,
|
|
ensureMainConversation,
|
|
isPersonalMainBot,
|
|
loadNotificationPolicy,
|
|
} from "@rakazo/db";
|
|
import { z } from "zod";
|
|
|
|
export const NOTIFY_USER_BODY_MAX_LENGTH = 200;
|
|
export const NOTIFY_USER_TITLE_MAX_LENGTH = 60;
|
|
|
|
const NotifyUserArgs = z.object({
|
|
body: z.string().trim().min(1).max(NOTIFY_USER_BODY_MAX_LENGTH),
|
|
title: z.string().trim().max(NOTIFY_USER_TITLE_MAX_LENGTH).optional(),
|
|
});
|
|
|
|
export interface NotifyUserToolContext {
|
|
userId: string;
|
|
spaceId: string;
|
|
runId: string;
|
|
bot: { id: string; name: string; kind: string };
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
export type NotifyUserResult = { error: string } | { sent: true } | { sent: false; reason: string };
|
|
|
|
/**
|
|
* A nudge from the main bot: a push that opens the main conversation. The user's nudge switch,
|
|
* quiet hours and the daily cap all apply; a held-back nudge is reported, not an error.
|
|
*/
|
|
export async function notifyUserFromTool(
|
|
deps: { prisma: PrismaClient; notifications?: NotificationProvider },
|
|
context: NotifyUserToolContext,
|
|
rawArgs: Record<string, unknown>,
|
|
): Promise<NotifyUserResult> {
|
|
if (
|
|
!(await isPersonalMainBot(deps.prisma, { kind: context.bot.kind, spaceId: context.spaceId }))
|
|
) {
|
|
return { error: "Only the user's main bot can notify the user." };
|
|
}
|
|
const parsed = NotifyUserArgs.safeParse(rawArgs);
|
|
if (!parsed.success) {
|
|
return {
|
|
error: `Invalid notification: body is required, up to ${NOTIFY_USER_BODY_MAX_LENGTH} characters; title up to ${NOTIFY_USER_TITLE_MAX_LENGTH}.`,
|
|
};
|
|
}
|
|
const { prisma, notifications } = deps;
|
|
if (!notifications) {
|
|
return { sent: false, reason: "Push notifications are not available on this server." };
|
|
}
|
|
const policy = await loadNotificationPolicy(prisma, context.userId);
|
|
if (!policy.nudges) return { sent: false, reason: "The user turned nudges off." };
|
|
if (policy.quiet) return { sent: false, reason: "It is the user's quiet hours." };
|
|
if ((await countPushSubscriptions(prisma, context.userId)) === 0) {
|
|
return { sent: false, reason: "The user has no device with notifications turned on." };
|
|
}
|
|
if (!(await claimDailyNudge(prisma, context.userId, policy.localDate))) {
|
|
return { sent: false, reason: `The daily limit of ${NUDGES_PER_DAY} nudges is reached.` };
|
|
}
|
|
const conversation = await ensureMainConversation(prisma, context.userId);
|
|
try {
|
|
await notifications.send(
|
|
{
|
|
kind: "nudge",
|
|
title: parsed.data.title || context.bot.name,
|
|
body: parsed.data.body,
|
|
botId: context.bot.id,
|
|
threadId: conversation.threadId,
|
|
},
|
|
{
|
|
operationId: `notify_user:${context.runId}`,
|
|
traceId: context.runId,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
botId: context.bot.id,
|
|
runId: context.runId,
|
|
signal: context.signal,
|
|
},
|
|
);
|
|
} catch {
|
|
return { sent: false, reason: "No device accepted the notification." };
|
|
}
|
|
return { sent: true };
|
|
}
|