diff --git a/__tests__/api/weekly-summary.test.ts b/__tests__/api/weekly-summary.test.ts new file mode 100644 index 00000000..f97050a2 --- /dev/null +++ b/__tests__/api/weekly-summary.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { POST } from "@/app/api/admin/send-weekly-summary/route" +import { registerAgent, resetAgentRegistryForTests } from "@/lib/agent-registry" +import { publishSystemEvent, resetPublishedEventsForTests } from "@/lib/events/system-events" +import { renderWeeklySummaryEmail, type AgentWeeklySummary } from "@/lib/email/weekly-summary" + +const sendMock = vi.fn() + +vi.mock("resend", () => ({ + Resend: vi.fn().mockImplementation(function ResendMock() { + return { emails: { send: sendMock } } + }), +})) + +const manifest = (agentId: string, email: string, emailOptOut = false) => ({ + agentId, + email, + emailOptOut, + model: "claude-haiku-4-5", + district: "data-center", + capabilities: ["quests"], + x402: { accepts: true }, + status: "active", + endpoint: `https://example.com/${agentId}`, +}) + +beforeEach(() => { + resetAgentRegistryForTests() + resetPublishedEventsForTests() + sendMock.mockReset() + sendMock.mockResolvedValue({ data: { id: "sandbox-email" }, error: null }) + process.env.MOLTBOT_GATEWAY_TOKEN = "admin-token" + process.env.RESEND_API_KEY = "re_test_sandbox" +}) + +describe("POST /api/admin/send-weekly-summary", () => { + it("sends weekly summaries to registered agent emails and respects opt-out", async () => { + registerAgent(manifest("agent-send", "Send@Example.com")) + registerAgent(manifest("agent-opt-out", "optout@example.com", true)) + + publishSystemEvent({ + type: "quest.completed", + agentId: "agent-send", + questId: "quest-1", + quest: { title: "Map the nebula" }, + occurredAt: "2026-06-24T12:00:00.000Z", + }) + publishSystemEvent({ + type: "agent.xp", + agentId: "agent-send", + xp: 75, + totalXp: 75, + level: 1, + occurredAt: "2026-06-24T12:01:00.000Z", + }) + publishSystemEvent({ + type: "badge.unlocked", + agentId: "agent-send", + badge: { id: "first-quest", name: "First Quest Completed", rarity: "common" }, + occurredAt: "2026-06-24T12:02:00.000Z", + }) + + const response = await POST(new Request("http://localhost/api/admin/send-weekly-summary", { + method: "POST", + headers: { authorization: "Bearer admin-token", "x-open-stellar-now": "2026-06-28T12:00:00.000Z" }, + })) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.ok).toBe(true) + expect(sendMock).toHaveBeenCalledTimes(1) + expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({ + to: "send@example.com", + subject: "Open Stellar weekly summary for agent-send", + text: expect.stringContaining("Map the nebula"), + html: expect.stringContaining("First Quest Completed"), + })) + expect(body.results).toEqual([ + expect.objectContaining({ agentId: "agent-send", to: "send@example.com", skipped: false }), + expect.objectContaining({ agentId: "agent-opt-out", to: "optout@example.com", skipped: true, reason: "agent opted out of email" }), + ]) + }) + + it("requires admin authorization", async () => { + const response = await POST(new Request("http://localhost/api/admin/send-weekly-summary", { method: "POST" })) + expect(response.status).toBe(401) + expect(sendMock).not.toHaveBeenCalled() + }) +}) + +describe("weekly summary template", () => { + const baseSummary: AgentWeeklySummary = { + agentId: "agent-template", + agentName: "agent-template", + questsCompleted: [], + xpEarned: 0, + level: 1, + previousLevel: null, + leveledUp: false, + leaderboardRank: null, + previousLeaderboardRank: null, + weekStartsAt: "2026-06-21T00:00:00.000Z", + weekEndsAt: "2026-06-28T00:00:00.000Z", + } + + it("renders the 0-quest scenario", () => { + const email = renderWeeklySummaryEmail(baseSummary) + expect(email.text).toContain("Quests completed: 0") + expect(email.text).toContain("No quests completed this week") + expect(email.html).toContain("Quests completed: 0") + }) + + it("renders the 5+-quest scenario", () => { + const email = renderWeeklySummaryEmail({ + ...baseSummary, + questsCompleted: Array.from({ length: 6 }, (_, index) => ({ + questId: `quest-${index}`, + title: `Quest ${index + 1}`, + completedAt: "2026-06-24T12:00:00.000Z", + })), + xpEarned: 360, + level: 2, + leveledUp: true, + leaderboardRank: 1, + previousLeaderboardRank: 3, + }) + expect(email.text).toContain("Quests completed: 6") + expect(email.text).toContain("6. Quest 6") + expect(email.text).toContain("New level: 2") + expect(email.html).toContain("
  • Quest 6
  • ") + }) +}) diff --git a/app/api/admin/send-weekly-summary/route.ts b/app/api/admin/send-weekly-summary/route.ts new file mode 100644 index 00000000..5dc4a78d --- /dev/null +++ b/app/api/admin/send-weekly-summary/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" + +import { listRegisteredAgents } from "@/lib/agent-registry" +import { isAuthorized } from "@/lib/auth" +import { sendRawEmail } from "@/lib/email/resend" +import { buildAgentWeeklySummary, renderWeeklySummaryEmail } from "@/lib/email/weekly-summary" + +export const dynamic = "force-dynamic" + +export async function POST(req: Request) { + if (!isAuthorized(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }) + } + + const nowHeader = req.headers.get("x-open-stellar-now") + const now = nowHeader ? new Date(nowHeader) : new Date() + if (Number.isNaN(now.getTime())) { + return NextResponse.json({ ok: false, error: "Invalid x-open-stellar-now header" }, { status: 400 }) + } + + const agents = listRegisteredAgents() + const allAgentIds = agents.map((agent) => agent.agentId) + const results = [] + + for (const agent of agents) { + if (!agent.email) { + results.push({ agentId: agent.agentId, skipped: true, reason: "agent has no email on file" }) + continue + } + + if (agent.emailOptOut) { + results.push({ agentId: agent.agentId, to: agent.email, skipped: true, reason: "agent opted out of email" }) + continue + } + + const summary = buildAgentWeeklySummary(agent.agentId, agent.agentId, allAgentIds, now) + const email = renderWeeklySummaryEmail(summary) + const result = await sendRawEmail({ to: agent.email, ...email }) + results.push({ agentId: agent.agentId, to: agent.email, ...result }) + } + + return NextResponse.json({ ok: true, results }, { headers: { "Cache-Control": "no-store" } }) +} diff --git a/lib/agent-registry.ts b/lib/agent-registry.ts index 71ce95b2..424e4c9d 100644 --- a/lib/agent-registry.ts +++ b/lib/agent-registry.ts @@ -14,6 +14,8 @@ export interface SkillRegistration { export interface AgentCapabilityManifest { agentId: string + email?: string + emailOptOut?: boolean model: string district: DistrictId capabilities: string[] @@ -70,6 +72,15 @@ function normalizeString(value: unknown, field: string): string { return value.trim() } +function normalizeOptionalEmail(value: unknown): string | undefined { + if (value === undefined) return undefined + const email = normalizeString(value, "email").toLowerCase() + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new Error("email must be a valid email address") + } + return email +} + function normalizeDistrict(value: unknown): DistrictId { const district = normalizeString(value, "district") if (!DISTRICTS.includes(district as DistrictId)) { @@ -221,11 +232,14 @@ export function registerAgent(input: unknown): AgentCapabilityManifest { const now = new Date().toISOString() const agentId = normalizeString(input.agentId, "agentId") + const email = normalizeOptionalEmail(input.email) const dependencies = normalizeDependencies(input.dependencies) validateDependencies(agentId, dependencies ?? []) const agent: AgentCapabilityManifest = { agentId, + ...(email === undefined ? {} : { email }), + ...(input.emailOptOut === undefined ? {} : { emailOptOut: input.emailOptOut === true }), model: normalizeString(input.model, "model"), district: normalizeDistrict(input.district), capabilities: normalizeCapabilities(input.capabilities), @@ -276,4 +290,4 @@ export function deregisterAgent(agentId: string): AgentCapabilityManifest | null export function resetAgentRegistryForTests(): void { registry.agents.clear() -} \ No newline at end of file +} diff --git a/lib/email/resend.ts b/lib/email/resend.ts index a3bff429..c8910e35 100644 --- a/lib/email/resend.ts +++ b/lib/email/resend.ts @@ -105,6 +105,24 @@ async function sendReactEmail(subject: string, to: string, react: React.ReactNod return { skipped: false, data } } +export async function sendRawEmail(options: { to: string; subject: string; text: string; html: string }) { + if (!process.env.RESEND_API_KEY) { + return { skipped: true, reason: "RESEND_API_KEY is not configured" } + } + + const resend = new Resend(process.env.RESEND_API_KEY) + const { data, error } = await resend.emails.send({ + from: fromAddress(), + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }) + + if (error) throw new Error(error.message) + return { skipped: false, data } +} + function BadgeUnlockedEmail({ agentName, badgeName, badgeRarity, unsubscribeUrl }: BadgeUnlockedEmailInput) { return React.createElement( "div", diff --git a/lib/email/weekly-summary.ts b/lib/email/weekly-summary.ts new file mode 100644 index 00000000..cfc68387 --- /dev/null +++ b/lib/email/weekly-summary.ts @@ -0,0 +1,136 @@ +import { getBadgeCatalogEntry, type BadgeRarity } from "@/lib/gamification/badge-catalog" +import { getAgentXP } from "@/lib/gamification/xp" +import { listPublishedEvents } from "@/lib/events/system-events" + +export interface WeeklyQuestSummaryItem { + questId: string + title: string + completedAt: string +} + +export interface WeeklyBadgeSummaryItem { + id: string + name: string + rarity?: BadgeRarity + xpValue: number + earnedAt: string +} + +export interface AgentWeeklySummary { + agentId: string + agentName: string + questsCompleted: WeeklyQuestSummaryItem[] + xpEarned: number + level: number + previousLevel: number | null + leveledUp: boolean + leaderboardRank: number | null + previousLeaderboardRank: number | null + topBadge?: WeeklyBadgeSummaryItem + weekStartsAt: string + weekEndsAt: string +} + +export interface WeeklySummaryTemplate { + subject: string + text: string + html: string +} + +const RARITY_WEIGHT: Record = { common: 1, uncommon: 2, rare: 3, epic: 4, legendary: 5 } + +export function getPreviousSundayWindow(now = new Date()): { start: Date; end: Date } { + const end = new Date(now) + end.setUTCHours(0, 0, 0, 0) + const daysSinceSunday = end.getUTCDay() + end.setUTCDate(end.getUTCDate() - daysSinceSunday) + const start = new Date(end) + start.setUTCDate(start.getUTCDate() - 7) + return { start, end } +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'") +} + +function rankText(current: number | null, previous: number | null): string { + if (current === null) return "Not ranked this week" + if (previous === null) return `#${current} (new on leaderboard)` + const delta = previous - current + if (delta > 0) return `#${current} (up ${delta} from last week)` + if (delta < 0) return `#${current} (down ${Math.abs(delta)} from last week)` + return `#${current} (unchanged from last week)` +} + +function eventTimeMs(event: { occurredAt?: string }): number { + return new Date(event.occurredAt ?? 0).getTime() +} + +function inWindow(event: { occurredAt?: string }, startMs: number, endMs: number): boolean { + const ms = eventTimeMs(event) + return ms >= startMs && ms < endMs +} + +function questTitle(quest: unknown, questId: string): string { + if (typeof quest === "object" && quest !== null && "title" in quest && typeof quest.title === "string") { + return quest.title + } + return questId || "Quest completed" +} + +function computeRank(agentIds: string[], startMs: number, endMs: number): Map { + const counts = new Map(agentIds.map((agentId) => [agentId, 0])) + for (const event of listPublishedEvents()) { + if (event.type !== "quest.completed" || !event.agentId || !inWindow(event, startMs, endMs)) continue + counts.set(event.agentId, (counts.get(event.agentId) ?? 0) + 1) + } + const sorted = [...counts.entries()].filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + const ranks = new Map() + let currentRank = 1 + for (let i = 0; i < sorted.length; i += 1) { + if (i > 0 && sorted[i]![1] < sorted[i - 1]![1]) currentRank = i + 1 + ranks.set(sorted[i]![0], currentRank) + } + return ranks +} + +export function buildAgentWeeklySummary(agentId: string, agentName: string, allAgentIds: string[], now = new Date()): AgentWeeklySummary { + const { start, end } = getPreviousSundayWindow(now) + const startMs = start.getTime() + const endMs = end.getTime() + const previousStartMs = startMs - 7 * 24 * 60 * 60 * 1000 + const events = listPublishedEvents().filter((event) => event.agentId === agentId && inWindow(event, startMs, endMs)) + const xpEvents = events.filter((event) => event.type === "agent.xp") + const xpEarned = xpEvents.reduce((sum, event) => sum + event.xp, 0) + const level = getAgentXP(agentId).level + const previousLevel = xpEvents.length > 0 ? xpEvents[0]!.level - (xpEvents[0]!.xp > 0 && xpEvents[0]!.level > 1 ? 1 : 0) : null + const badges = events.filter((event) => event.type === "badge.unlocked").map((event) => { + const catalog = getBadgeCatalogEntry(event.badge.id) + return { id: event.badge.id, name: event.badge.name, rarity: event.badge.rarity, xpValue: catalog?.xpValue ?? 0, earnedAt: event.occurredAt } + }).sort((a, b) => (RARITY_WEIGHT[b.rarity ?? "common"] - RARITY_WEIGHT[a.rarity ?? "common"]) || b.xpValue - a.xpValue) + return { + agentId, + agentName, + questsCompleted: events.filter((event) => event.type === "quest.completed").map((event) => ({ questId: event.questId ?? "", title: questTitle(event.quest, event.questId ?? ""), completedAt: event.occurredAt })), + xpEarned, + level, + previousLevel, + leveledUp: xpEvents.some((event) => event.level > level - 1), + leaderboardRank: computeRank(allAgentIds, startMs, endMs).get(agentId) ?? null, + previousLeaderboardRank: computeRank(allAgentIds, previousStartMs, startMs).get(agentId) ?? null, + topBadge: badges[0], + weekStartsAt: start.toISOString(), + weekEndsAt: end.toISOString(), + } +} + +export function renderWeeklySummaryEmail(summary: AgentWeeklySummary): WeeklySummaryTemplate { + const questLines = summary.questsCompleted.length === 0 ? ["No quests completed this week. Your next quest is waiting."] : summary.questsCompleted.map((quest, index) => `${index + 1}. ${quest.title}`) + const levelLine = summary.leveledUp ? `New level: ${summary.level}` : `Current level: ${summary.level}` + const badgeLine = summary.topBadge ? `${summary.topBadge.name}${summary.topBadge.rarity ? ` (${summary.topBadge.rarity})` : ""}` : "No badges earned this week" + const subject = `Open Stellar weekly summary for ${summary.agentName}` + const text = [`Open Stellar weekly summary`, `Agent: ${summary.agentName}`, `Week: ${summary.weekStartsAt.slice(0, 10)} to ${summary.weekEndsAt.slice(0, 10)}`, "", `Quests completed: ${summary.questsCompleted.length}`, ...questLines, "", `XP earned: ${summary.xpEarned}`, levelLine, `Leaderboard rank: ${rankText(summary.leaderboardRank, summary.previousLeaderboardRank)}`, `Top badge: ${badgeLine}`].join("\n") + const htmlQuests = summary.questsCompleted.length === 0 ? "

    No quests completed this week. Your next quest is waiting.

    " : `
      ${summary.questsCompleted.map((quest) => `
    1. ${escapeHtml(quest.title)}
    2. `).join("")}
    ` + const html = `

    Open Stellar weekly summary

    Agent: ${escapeHtml(summary.agentName)}

    Week: ${summary.weekStartsAt.slice(0, 10)} to ${summary.weekEndsAt.slice(0, 10)}

    Quests completed: ${summary.questsCompleted.length}

    ${htmlQuests}

    XP earned: ${summary.xpEarned}

    ${summary.leveledUp ? "New" : "Current"} level: ${summary.level}

    Leaderboard rank: ${escapeHtml(rankText(summary.leaderboardRank, summary.previousLeaderboardRank))}

    Top badge: ${escapeHtml(badgeLine)}

    ` + return { subject, text, html } +} diff --git a/lib/events/system-events.ts b/lib/events/system-events.ts index 7274025d..0ec983a7 100644 --- a/lib/events/system-events.ts +++ b/lib/events/system-events.ts @@ -57,6 +57,7 @@ type EventListener = (event: PublishedSystemEvent) => void interface EventBusState { listeners: Set sequence: number + events: PublishedSystemEvent[] } const globalState = globalThis as typeof globalThis & { @@ -66,6 +67,7 @@ const globalState = globalThis as typeof globalThis & { const eventBus: EventBusState = globalState.__openStellarEventBus__ ?? { listeners: new Set(), sequence: 0, + events: [], } if (!globalState.__openStellarEventBus__) { @@ -94,12 +96,22 @@ export function eventMatchesAgent(event: PublishedSystemEvent, agentId?: string) export function publishSystemEvent(event: SystemEvent): PublishedSystemEvent { const published = ensurePublishedEvent(event) + eventBus.events.push(published) for (const listener of eventBus.listeners) { listener(published) } return published } +export function listPublishedEvents(): PublishedSystemEvent[] { + return [...eventBus.events] +} + +export function resetPublishedEventsForTests(): void { + eventBus.events.length = 0 + eventBus.sequence = 0 +} + export function subscribeToSystemEvents(listener: EventListener) { eventBus.listeners.add(listener) return () => {