Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions __tests__/api/weekly-summary.test.ts
Original file line number Diff line number Diff line change
@@ -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("<li>Quest 6</li>")
})
})
43 changes: 43 additions & 0 deletions app/api/admin/send-weekly-summary/route.ts
Original file line number Diff line number Diff line change
@@ -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" } })
}
16 changes: 15 additions & 1 deletion lib/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

export interface AgentCapabilityManifest {
agentId: string
email?: string
emailOptOut?: boolean
model: string
district: DistrictId
capabilities: string[]
Expand Down Expand Up @@ -70,6 +72,15 @@
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)) {

Check warning on line 78 in lib/agent-registry.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLTQanoR1oNlMfKZG&open=AZ8ZLTQanoR1oNlMfKZG&pullRequest=415
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)) {
Expand Down Expand Up @@ -221,11 +232,14 @@

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),
Expand Down Expand Up @@ -276,4 +290,4 @@

export function resetAgentRegistryForTests(): void {
registry.agents.clear()
}
}
18 changes: 18 additions & 0 deletions lib/email/resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
136 changes: 136 additions & 0 deletions lib/email/weekly-summary.ts
Original file line number Diff line number Diff line change
@@ -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<BadgeRarity, number> = { 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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;")
}

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<string, number> {
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<string, number>()
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

Check warning on line 106 in lib/email/weekly-summary.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLTJ4noR1oNlMfKZC&open=AZ8ZLTJ4noR1oNlMfKZC&pullRequest=415
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"

Check warning on line 130 in lib/email/weekly-summary.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLTJ4noR1oNlMfKZD&open=AZ8ZLTJ4noR1oNlMfKZD&pullRequest=415

Check warning on line 130 in lib/email/weekly-summary.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLTJ4noR1oNlMfKZE&open=AZ8ZLTJ4noR1oNlMfKZE&pullRequest=415
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 ? "<p>No quests completed this week. Your next quest is waiting.</p>" : `<ol>${summary.questsCompleted.map((quest) => `<li>${escapeHtml(quest.title)}</li>`).join("")}</ol>`

Check warning on line 133 in lib/email/weekly-summary.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLTJ4noR1oNlMfKZF&open=AZ8ZLTJ4noR1oNlMfKZF&pullRequest=415
const html = `<div style="font-family:Arial,sans-serif;color:#102033;line-height:1.5"><h1>Open Stellar weekly summary</h1><p><strong>Agent:</strong> ${escapeHtml(summary.agentName)}</p><p><strong>Week:</strong> ${summary.weekStartsAt.slice(0, 10)} to ${summary.weekEndsAt.slice(0, 10)}</p><h2>Quests completed: ${summary.questsCompleted.length}</h2>${htmlQuests}<p><strong>XP earned:</strong> ${summary.xpEarned}</p><p><strong>${summary.leveledUp ? "New" : "Current"} level:</strong> ${summary.level}</p><p><strong>Leaderboard rank:</strong> ${escapeHtml(rankText(summary.leaderboardRank, summary.previousLeaderboardRank))}</p><p><strong>Top badge:</strong> ${escapeHtml(badgeLine)}</p></div>`
return { subject, text, html }
}
Loading
Loading