evOo-local/packages/adapters/src/notify-user-tool.ts
Claude 77e822b9a7
Merge main into the Phase 2 integration branch
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
2026-10-03 23:24:21 +00:00

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