From 2f57c7d4a46ff5d2ac5cb70513a19598288d5ea4 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 24 Mar 2026 22:20:17 +0000 Subject: [PATCH 01/16] feat: Recoup Content Agent Slack bot and /api/launch endpoint Add content-agent Slack bot with mention handler for content generation, callback endpoint for Trigger.dev task results, and /api/launch Release Autopilot streaming endpoint. Fixes from code review: - Remove ~90 unrelated JSDoc-only changes to existing files - Rename handlers/handleContentAgentCallback.ts to registerOnSubscribedMessage.ts to resolve naming collision with the top-level handler - Use crypto.timingSafeEqual for callback secret comparison - Fix all JSDoc lint errors in new files Co-Authored-By: Paperclip --- app/api/content-agent/[platform]/route.ts | 63 ++++++++ app/api/content-agent/callback/route.ts | 17 ++ app/api/launch/route.ts | 51 ++++++ lib/content-agent/bot.ts | 54 +++++++ lib/content-agent/getThread.ts | 18 +++ .../handleContentAgentCallback.ts | 88 +++++++++++ .../handlers/handleContentAgentMention.ts | 146 ++++++++++++++++++ .../handlers/registerHandlers.ts | 10 ++ .../handlers/registerOnSubscribedMessage.ts | 17 ++ lib/content-agent/types.ts | 12 ++ .../validateContentAgentCallback.ts | 49 ++++++ lib/content-agent/validateEnv.ts | 19 +++ .../__tests__/validateLaunchBody.test.ts | 98 ++++++++++++ lib/launch/buildCampaignPrompt.ts | 61 ++++++++ lib/launch/generateCampaignHandler.ts | 35 +++++ lib/launch/validateLaunchBody.ts | 44 ++++++ lib/trigger/triggerPollContentRun.ts | 18 +++ 17 files changed, 800 insertions(+) create mode 100644 app/api/content-agent/[platform]/route.ts create mode 100644 app/api/content-agent/callback/route.ts create mode 100644 app/api/launch/route.ts create mode 100644 lib/content-agent/bot.ts create mode 100644 lib/content-agent/getThread.ts create mode 100644 lib/content-agent/handleContentAgentCallback.ts create mode 100644 lib/content-agent/handlers/handleContentAgentMention.ts create mode 100644 lib/content-agent/handlers/registerHandlers.ts create mode 100644 lib/content-agent/handlers/registerOnSubscribedMessage.ts create mode 100644 lib/content-agent/types.ts create mode 100644 lib/content-agent/validateContentAgentCallback.ts create mode 100644 lib/content-agent/validateEnv.ts create mode 100644 lib/launch/__tests__/validateLaunchBody.test.ts create mode 100644 lib/launch/buildCampaignPrompt.ts create mode 100644 lib/launch/generateCampaignHandler.ts create mode 100644 lib/launch/validateLaunchBody.ts create mode 100644 lib/trigger/triggerPollContentRun.ts diff --git a/app/api/content-agent/[platform]/route.ts b/app/api/content-agent/[platform]/route.ts new file mode 100644 index 00000000..d3f2dfc4 --- /dev/null +++ b/app/api/content-agent/[platform]/route.ts @@ -0,0 +1,63 @@ +import type { NextRequest } from "next/server"; +import { after } from "next/server"; +import { contentAgentBot } from "@/lib/content-agent/bot"; +import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; +import "@/lib/content-agent/handlers/registerHandlers"; + +/** + * GET /api/content-agent/[platform] + * + * Handles webhook verification handshakes for the content agent bot. + * + * @param request - The incoming verification request + * @param params - Route params wrapper + * @param params.params - Promise resolving to the platform name + * @returns The webhook verification response + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ platform: string }> }, +) { + const { platform } = await params; + + const handler = contentAgentBot.webhooks[platform as keyof typeof contentAgentBot.webhooks]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); +} + +/** + * POST /api/content-agent/[platform] + * + * Webhook endpoint for the content agent bot. + * Handles Slack webhooks via dynamic [platform] segment. + * + * @param request - The incoming webhook request + * @param params - Route params wrapper + * @param params.params - Promise resolving to the platform name + * @returns The webhook response + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ platform: string }> }, +) { + const { platform } = await params; + + if (platform === "slack") { + const verification = await handleUrlVerification(request); + if (verification) return verification; + } + + await contentAgentBot.initialize(); + + const handler = contentAgentBot.webhooks[platform as keyof typeof contentAgentBot.webhooks]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); +} diff --git a/app/api/content-agent/callback/route.ts b/app/api/content-agent/callback/route.ts new file mode 100644 index 00000000..476af1e6 --- /dev/null +++ b/app/api/content-agent/callback/route.ts @@ -0,0 +1,17 @@ +import type { NextRequest } from "next/server"; +import { contentAgentBot } from "@/lib/content-agent/bot"; +import { handleContentAgentCallback } from "@/lib/content-agent/handleContentAgentCallback"; + +/** + * POST /api/content-agent/callback + * + * Callback endpoint for the poll-content-run Trigger.dev task. + * Receives task results and posts them back to the Slack thread. + * + * @param request - The incoming callback request + * @returns The callback response + */ +export async function POST(request: NextRequest) { + await contentAgentBot.initialize(); + return handleContentAgentCallback(request); +} diff --git a/app/api/launch/route.ts b/app/api/launch/route.ts new file mode 100644 index 00000000..5c6ec554 --- /dev/null +++ b/app/api/launch/route.ts @@ -0,0 +1,51 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateCampaignHandler } from "@/lib/launch/generateCampaignHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 200 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/launch + * + * Streams an AI-generated music release campaign given artist and song details. + * Returns a text/plain stream with XML-style section markers that the client + * parses to render each campaign section in real-time. + * + * Authentication: x-api-key header OR Authorization: Bearer token required. + * + * Request body: + * - artist_name: string (required) — the artist's name + * - song_name: string (required) — the song or album name + * - genre: string (required) — musical genre + * - release_date: string (required) — release date (any format) + * - description: string (optional) — additional context for the AI + * + * Response: streaming text with section markers: + * [SECTION:press_release]...[/SECTION:press_release] + * [SECTION:spotify_pitch]...[/SECTION:spotify_pitch] + * [SECTION:instagram_captions]...[/SECTION:instagram_captions] + * [SECTION:tiktok_hooks]...[/SECTION:tiktok_hooks] + * [SECTION:fan_newsletter]...[/SECTION:fan_newsletter] + * [SECTION:curator_email]...[/SECTION:curator_email] + * + * @param request - The incoming request + * @returns Streaming text response or error + */ +export async function POST(request: NextRequest): Promise { + return generateCampaignHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/content-agent/bot.ts b/lib/content-agent/bot.ts new file mode 100644 index 00000000..97db2599 --- /dev/null +++ b/lib/content-agent/bot.ts @@ -0,0 +1,54 @@ +import { Chat, ConsoleLogger } from "chat"; +import { SlackAdapter } from "@chat-adapter/slack"; +import { createIoRedisState } from "@chat-adapter/state-ioredis"; +import redis from "@/lib/redis/connection"; +import type { ContentAgentThreadState } from "./types"; +import { validateContentAgentEnv } from "./validateEnv"; + +const logger = new ConsoleLogger(); + +type ContentAgentAdapters = { + slack: SlackAdapter; +}; + +/** + * Creates a new Chat bot instance configured with the Slack adapter + * for the Recoup Content Agent. + * + * @returns The configured Chat bot instance + */ +export function createContentAgentBot() { + validateContentAgentEnv(); + + if (redis.status === "wait") { + redis.connect().catch(() => { + throw new Error("[content-agent] Redis failed to connect"); + }); + } + + const state = createIoRedisState({ + client: redis, + keyPrefix: "content-agent", + logger, + }); + + const slack = new SlackAdapter({ + botToken: process.env.SLACK_CONTENT_BOT_TOKEN!, + signingSecret: process.env.SLACK_CONTENT_SIGNING_SECRET!, + logger, + }); + + return new Chat({ + userName: "Recoup Content Agent", + adapters: { slack }, + state, + }); +} + +export type ContentAgentBot = ReturnType; + +/** + * Singleton bot instance. Registers as the Chat SDK singleton + * so ThreadImpl can resolve adapters lazily from thread IDs. + */ +export const contentAgentBot = createContentAgentBot().registerSingleton(); diff --git a/lib/content-agent/getThread.ts b/lib/content-agent/getThread.ts new file mode 100644 index 00000000..8aa75d55 --- /dev/null +++ b/lib/content-agent/getThread.ts @@ -0,0 +1,18 @@ +import { ThreadImpl } from "chat"; +import type { ContentAgentThreadState } from "./types"; + +/** + * Reconstructs a Thread from a stored thread ID using the Chat SDK singleton. + * + * @param threadId - The stored thread identifier (format: adapter:channel:thread) + * @returns The reconstructed Thread instance + */ +export function getThread(threadId: string) { + const adapterName = threadId.split(":")[0]; + const channelId = `${adapterName}:${threadId.split(":")[1]}`; + return new ThreadImpl({ + adapterName, + id: threadId, + channelId, + }); +} diff --git a/lib/content-agent/handleContentAgentCallback.ts b/lib/content-agent/handleContentAgentCallback.ts new file mode 100644 index 00000000..936feb8f --- /dev/null +++ b/lib/content-agent/handleContentAgentCallback.ts @@ -0,0 +1,88 @@ +import { timingSafeEqual } from "crypto"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateContentAgentCallback } from "./validateContentAgentCallback"; +import { getThread } from "./getThread"; + +/** + * Handles content agent task callback from the poll-content-run Trigger.dev task. + * Verifies the shared secret and posts results back to the Slack thread. + * + * @param request - The incoming callback request + * @returns A NextResponse + */ +export async function handleContentAgentCallback(request: Request): Promise { + const secret = request.headers.get("x-callback-secret"); + const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; + + if ( + !secret || + !expectedSecret || + secret.length !== expectedSecret.length || + !timingSafeEqual(Buffer.from(secret), Buffer.from(expectedSecret)) + ) { + return NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateContentAgentCallback(body); + + if (validated instanceof NextResponse) { + return validated; + } + + const thread = getThread(validated.threadId); + + switch (validated.status) { + case "completed": { + const results = validated.results ?? []; + const videos = results.filter(r => r.status === "completed" && r.videoUrl); + const failed = results.filter(r => r.status === "failed"); + + if (videos.length > 0) { + const lines = videos.map((v, i) => { + const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; + const caption = v.captionText ? `\n> ${v.captionText}` : ""; + return `${label}${v.videoUrl}${caption}`; + }); + + if (failed.length > 0) { + lines.push(`\n_${failed.length} run(s) failed._`); + } + + await thread.post(lines.join("\n\n")); + } else { + await thread.post("Content generation finished but no videos were produced."); + } + + await thread.setState({ status: "completed" }); + break; + } + + case "failed": + await thread.setState({ status: "failed" }); + await thread.post(`Content generation failed: ${validated.message ?? "Unknown error"}`); + break; + + case "timeout": + await thread.setState({ status: "timeout" }); + await thread.post( + "Content generation timed out after 30 minutes. The pipeline may still be running — check the Trigger.dev dashboard.", + ); + break; + } + + return NextResponse.json({ status: "ok" }, { headers: getCorsHeaders() }); +} diff --git a/lib/content-agent/handlers/handleContentAgentMention.ts b/lib/content-agent/handlers/handleContentAgentMention.ts new file mode 100644 index 00000000..81e4105e --- /dev/null +++ b/lib/content-agent/handlers/handleContentAgentMention.ts @@ -0,0 +1,146 @@ +import type { ContentAgentBot } from "../bot"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { triggerPollContentRun } from "@/lib/trigger/triggerPollContentRun"; +import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { + DEFAULT_CONTENT_TEMPLATE, + isSupportedContentTemplate, +} from "@/lib/content/contentTemplates"; + +/** + * Parses the mention text into content generation parameters. + * + * Format: [template] [batch=N] [lipsync] + * + * @param text - The raw mention text to parse + * @returns Parsed content generation parameters + */ +function parseMentionArgs(text: string) { + const tokens = text.trim().split(/\s+/); + const artistAccountId = tokens[0]; + let template = DEFAULT_CONTENT_TEMPLATE; + let batch = 1; + let lipsync = false; + + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i].toLowerCase(); + if (token.startsWith("batch=")) { + const n = parseInt(token.split("=")[1], 10); + if (!isNaN(n) && n >= 1 && n <= 30) batch = n; + } else if (token === "lipsync") { + lipsync = true; + } else if (!token.startsWith("batch") && token !== "lipsync") { + template = tokens[i]; // preserve original case for template name + } + } + + return { artistAccountId, template, batch, lipsync }; +} + +/** + * Registers the onNewMention handler on the content agent bot. + * Parses the mention text, validates the artist, triggers content creation, + * and starts a polling task to report results back. + * + * @param bot - The content agent bot instance to register the handler on + */ +export function registerOnNewMention(bot: ContentAgentBot) { + bot.onNewMention(async (thread, message) => { + try { + const { artistAccountId, template, batch, lipsync } = parseMentionArgs(message.text); + + if (!artistAccountId) { + await thread.post( + "Please provide an artist account ID.\n\nUsage: `@RecoupContentAgent [template] [batch=N] [lipsync]`", + ); + return; + } + + if (!isSupportedContentTemplate(template)) { + await thread.post(`Unsupported template: \`${template}\`. Check available templates.`); + return; + } + + // Resolve artist slug + const artistSlug = await resolveArtistSlug(artistAccountId); + if (!artistSlug) { + await thread.post( + `Artist not found for account ID \`${artistAccountId}\`. Please check the ID and try again.`, + ); + return; + } + + // Resolve GitHub repo + let githubRepo: string; + try { + const readiness = await getArtistContentReadiness({ + accountId: artistAccountId, + artistAccountId, + artistSlug, + }); + githubRepo = readiness.githubRepo; + } catch { + const snapshots = await selectAccountSnapshots(artistAccountId); + const repo = snapshots?.[0]?.github_repo; + if (!repo) { + await thread.post( + `No GitHub repository found for artist \`${artistSlug}\`. Content creation requires a configured repo.`, + ); + return; + } + githubRepo = repo; + } + + // Post acknowledgment + const batchNote = batch > 1 ? ` (${batch} videos)` : ""; + const lipsyncNote = lipsync ? " with lipsync" : ""; + await thread.post( + `Generating content for **${artistSlug}**${batchNote}${lipsyncNote}... Template: \`${template}\`. I'll reply here when ready (~5-10 min).`, + ); + + // Trigger content creation + const payload = { + accountId: artistAccountId, + artistSlug, + template, + lipsync, + captionLength: "short" as const, + upscale: false, + githubRepo, + }; + + const results = await Promise.allSettled( + Array.from({ length: batch }, () => triggerCreateContent(payload)), + ); + const runIds = results + .filter(r => r.status === "fulfilled") + .map(r => (r as PromiseFulfilledResult<{ id: string }>).value.id); + + if (runIds.length === 0) { + await thread.post("Failed to trigger content creation. Please try again."); + return; + } + + // Set thread state + await thread.setState({ + status: "running", + artistAccountId, + template, + lipsync, + batch, + runIds, + }); + + // Trigger polling task + await triggerPollContentRun({ + runIds, + callbackThreadId: thread.id, + }); + } catch (error) { + console.error("[content-agent] onNewMention error:", error); + await thread.post("Something went wrong starting content generation. Please try again."); + } + }); +} diff --git a/lib/content-agent/handlers/registerHandlers.ts b/lib/content-agent/handlers/registerHandlers.ts new file mode 100644 index 00000000..81657385 --- /dev/null +++ b/lib/content-agent/handlers/registerHandlers.ts @@ -0,0 +1,10 @@ +import { contentAgentBot } from "../bot"; +import { registerOnNewMention } from "./handleContentAgentMention"; +import { registerOnSubscribedMessage } from "./registerOnSubscribedMessage"; + +/** + * Registers all content agent event handlers on the bot singleton. + * Import this file once to attach handlers to the bot. + */ +registerOnNewMention(contentAgentBot); +registerOnSubscribedMessage(contentAgentBot); diff --git a/lib/content-agent/handlers/registerOnSubscribedMessage.ts b/lib/content-agent/handlers/registerOnSubscribedMessage.ts new file mode 100644 index 00000000..df89bbeb --- /dev/null +++ b/lib/content-agent/handlers/registerOnSubscribedMessage.ts @@ -0,0 +1,17 @@ +import type { ContentAgentBot } from "../bot"; + +/** + * Registers the onSubscribedMessage handler for the content agent. + * Handles replies in active threads while content is being generated. + * + * @param bot - The content agent bot instance to register the handler on + */ +export function registerOnSubscribedMessage(bot: ContentAgentBot) { + bot.onSubscribedMessage(async (thread, _) => { + const state = await thread.state; + + if (state?.status === "running") { + await thread.post("Still generating your content. I'll reply here when it's ready."); + } + }); +} diff --git a/lib/content-agent/types.ts b/lib/content-agent/types.ts new file mode 100644 index 00000000..bae35862 --- /dev/null +++ b/lib/content-agent/types.ts @@ -0,0 +1,12 @@ +/** + * Thread state for the content agent bot. + * Stored in Redis via Chat SDK's state adapter. + */ +export interface ContentAgentThreadState { + status: "running" | "completed" | "failed" | "timeout"; + artistAccountId: string; + template: string; + lipsync: boolean; + batch: number; + runIds: string[]; +} diff --git a/lib/content-agent/validateContentAgentCallback.ts b/lib/content-agent/validateContentAgentCallback.ts new file mode 100644 index 00000000..73fd6d9c --- /dev/null +++ b/lib/content-agent/validateContentAgentCallback.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +const contentRunResultSchema = z.object({ + runId: z.string(), + status: z.enum(["completed", "failed", "timeout"]), + videoUrl: z.string().optional(), + captionText: z.string().optional(), + error: z.string().optional(), +}); + +export const contentAgentCallbackSchema = z.object({ + threadId: z.string({ message: "threadId is required" }).min(1, "threadId cannot be empty"), + status: z.enum(["completed", "failed", "timeout"]), + results: z.array(contentRunResultSchema).optional(), + message: z.string().optional(), +}); + +export type ContentAgentCallbackBody = z.infer; + +/** + * Validates the content agent callback body against the expected schema. + * + * @param body - The parsed JSON body of the callback request. + * @returns A NextResponse with an error if validation fails, or the validated body. + */ +export function validateContentAgentCallback( + body: unknown, +): NextResponse | ContentAgentCallbackBody { + const result = contentAgentCallbackSchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/content-agent/validateEnv.ts b/lib/content-agent/validateEnv.ts new file mode 100644 index 00000000..551b0bb4 --- /dev/null +++ b/lib/content-agent/validateEnv.ts @@ -0,0 +1,19 @@ +const REQUIRED_ENV_VARS = [ + "SLACK_CONTENT_BOT_TOKEN", + "SLACK_CONTENT_SIGNING_SECRET", + "CONTENT_AGENT_CALLBACK_SECRET", + "REDIS_URL", +] as const; + +/** + * Validates that all required environment variables for the content agent are set. + * Throws an error listing all missing variables. + */ +export function validateContentAgentEnv(): void { + const missing = REQUIRED_ENV_VARS.filter(name => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `[content-agent] Missing required environment variables:\n${missing.map(v => ` - ${v}`).join("\n")}`, + ); + } +} diff --git a/lib/launch/__tests__/validateLaunchBody.test.ts b/lib/launch/__tests__/validateLaunchBody.test.ts new file mode 100644 index 00000000..378ea301 --- /dev/null +++ b/lib/launch/__tests__/validateLaunchBody.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validateLaunchBody } from "../validateLaunchBody"; + +/** + * Remove a key from an object — test utility. + * + * @param obj - Source object + * @param key - Key to remove + * @returns Object without the specified key + */ +function omit(obj: T, key: K): Omit { + const copy = { ...obj }; + delete copy[key]; + return copy; +} + +describe("validateLaunchBody", () => { + const validBody = { + artist_name: "Gliiico", + song_name: "Midnight Drive", + genre: "Indie Pop", + release_date: "2026-04-01", + description: "A song about late night drives and nostalgia", + }; + + describe("successful cases", () => { + it("returns parsed body when all required fields are present", () => { + const result = validateLaunchBody(validBody); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.artist_name).toBe("Gliiico"); + expect(result.song_name).toBe("Midnight Drive"); + expect(result.genre).toBe("Indie Pop"); + expect(result.release_date).toBe("2026-04-01"); + expect(result.description).toBe("A song about late night drives and nostalgia"); + } + }); + + it("accepts body without optional description", () => { + const result = validateLaunchBody(omit(validBody, "description")); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.description).toBeUndefined(); + } + }); + }); + + describe("error cases", () => { + it("returns 400 when artist_name is missing", () => { + const result = validateLaunchBody(omit(validBody, "artist_name")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when song_name is missing", () => { + const result = validateLaunchBody(omit(validBody, "song_name")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when genre is missing", () => { + const result = validateLaunchBody(omit(validBody, "genre")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when release_date is missing", () => { + const result = validateLaunchBody(omit(validBody, "release_date")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when artist_name is empty string", () => { + const result = validateLaunchBody({ ...validBody, artist_name: "" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when body is null", () => { + const result = validateLaunchBody(null); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + }); +}); diff --git a/lib/launch/buildCampaignPrompt.ts b/lib/launch/buildCampaignPrompt.ts new file mode 100644 index 00000000..f14e35ce --- /dev/null +++ b/lib/launch/buildCampaignPrompt.ts @@ -0,0 +1,61 @@ +import type { LaunchBody } from "./validateLaunchBody"; + +/** + * Builds the system prompt for the release campaign generator. + * + * @returns The system prompt string + */ +export function buildCampaignSystemPrompt(): string { + return `You are an expert music industry publicist and marketing strategist with 15+ years of experience +launching indie and major label artists. You write compelling, professional, and authentic music PR content +that sounds human — never generic. Your press releases get picked up by music blogs. Your Spotify pitches +get playlisted. Your emails get replies. + +Always write as if this artist and song are genuinely exciting. Use vivid, specific language. +Avoid clichés like "sonic journey" or "genre-defying." +Output each section EXACTLY as instructed with the section markers provided — no extra text outside the markers.`; +} + +/** + * Builds the user prompt for the release campaign generator. + * + * @param body - Validated launch request body + * @returns The formatted user prompt + */ +export function buildCampaignUserPrompt(body: LaunchBody): string { + const { artist_name, song_name, genre, release_date, description } = body; + + const context = description ? `\nAdditional context: ${description}` : ""; + + return `Generate a complete music release campaign for: +Artist: ${artist_name} +Song/Release: ${song_name} +Genre: ${genre} +Release Date: ${release_date}${context} + +Generate each section IN ORDER using EXACTLY these markers (do not skip or reorder): + +[SECTION:press_release] +Write a professional 2-3 paragraph press release announcing the release. Include a punchy headline, vivid description of the song's sound, the artist's backstory in 1 sentence, and a quote from the artist. End with release date and where to stream. +[/SECTION:press_release] + +[SECTION:spotify_pitch] +Write a compelling Spotify editorial pitch (200-250 words). Describe the song's sonic DNA, emotional core, lyrical themes, production style, and which playlists it fits. Include 3 specific Spotify playlist names this would fit. +[/SECTION:spotify_pitch] + +[SECTION:instagram_captions] +Write 5 different Instagram captions for the release announcement post. Vary the tone: one hype, one personal/vulnerable, one cryptic/teaser, one funny, one simple & clean. Include 5-8 relevant hashtags for each. +[/SECTION:instagram_captions] + +[SECTION:tiktok_hooks] +Write 5 different TikTok video hook scripts. Each hook is the first 3 seconds of a video — punchy, scroll-stopping. Format as: "Hook [N]: [script]". Make them specific to the song's vibe. +[/SECTION:tiktok_hooks] + +[SECTION:fan_newsletter] +Write an email newsletter to fans announcing the release. Start with a compelling subject line on its own line (format: "Subject: [subject]"), then the email body. Make it personal, like the artist is writing directly to fans. +[/SECTION:fan_newsletter] + +[SECTION:curator_email] +Write a cold email to a playlist curator pitching the song for placement. Keep it under 150 words. Subject line first (format: "Subject: [subject]"), then body. Be specific, not salesy. Include the Spotify link placeholder [SPOTIFY_LINK]. +[/SECTION:curator_email]`; +} diff --git a/lib/launch/generateCampaignHandler.ts b/lib/launch/generateCampaignHandler.ts new file mode 100644 index 00000000..eef2f922 --- /dev/null +++ b/lib/launch/generateCampaignHandler.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { streamText } from "ai"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateLaunchBody } from "./validateLaunchBody"; +import { buildCampaignSystemPrompt, buildCampaignUserPrompt } from "./buildCampaignPrompt"; +import { DEFAULT_MODEL } from "@/lib/const"; + +/** + * Handles POST /api/launch — streams an AI-generated release campaign. + * + * @param request - The incoming request + * @returns A streaming text response containing the campaign sections + */ +export async function generateCampaignHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const json = await request.json(); + const validated = validateLaunchBody(json); + if (validated instanceof NextResponse) { + return validated; + } + + const result = streamText({ + model: DEFAULT_MODEL, + system: buildCampaignSystemPrompt(), + prompt: buildCampaignUserPrompt(validated), + }); + + return result.toTextStreamResponse({ headers: getCorsHeaders() }); +} diff --git a/lib/launch/validateLaunchBody.ts b/lib/launch/validateLaunchBody.ts new file mode 100644 index 00000000..ea67ab8a --- /dev/null +++ b/lib/launch/validateLaunchBody.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const launchBodySchema = z.object({ + artist_name: z + .string({ message: "artist_name is required" }) + .min(1, "artist_name cannot be empty"), + song_name: z.string({ message: "song_name is required" }).min(1, "song_name cannot be empty"), + genre: z.string({ message: "genre is required" }).min(1, "genre cannot be empty"), + release_date: z + .string({ message: "release_date is required" }) + .min(1, "release_date cannot be empty"), + description: z.string().optional(), +}); + +export type LaunchBody = z.infer; + +/** + * Validates request body for POST /api/launch. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateLaunchBody(body: unknown): NextResponse | LaunchBody { + const result = launchBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/trigger/triggerPollContentRun.ts b/lib/trigger/triggerPollContentRun.ts new file mode 100644 index 00000000..f6b1d8a8 --- /dev/null +++ b/lib/trigger/triggerPollContentRun.ts @@ -0,0 +1,18 @@ +import { tasks } from "@trigger.dev/sdk"; + +type PollContentRunPayload = { + runIds: string[]; + callbackThreadId: string; +}; + +/** + * Triggers the poll-content-run task to monitor content creation runs + * and post results back to the Slack thread via callback. + * + * @param payload - The run IDs to poll and the callback thread ID + * @returns The task handle with runId + */ +export async function triggerPollContentRun(payload: PollContentRunPayload) { + const handle = await tasks.trigger("poll-content-run", payload); + return handle; +} From 26db957d7a894fc16478b52fbe55bf8d1cd86a38 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 24 Mar 2026 23:08:10 +0000 Subject: [PATCH 02/16] fix: lazy bot init and thread ID validation (review feedback) - bot.ts: Replace eager module-scope singleton with lazy getContentAgentBot() so Vercel build does not crash when content-agent env vars are not yet configured - getThread.ts: Add regex validation for adapter:channel:thread format, throw descriptive error on malformed IDs - registerHandlers.ts: Convert side-effect import to explicit ensureHandlersRegistered() call with idempotency guard - Route files updated to use getContentAgentBot() and ensureHandlersRegistered() Co-Authored-By: Paperclip --- app/api/content-agent/[platform]/route.ts | 14 +++++++++----- app/api/content-agent/callback/route.ts | 4 ++-- lib/content-agent/bot.ts | 16 +++++++++++++--- lib/content-agent/getThread.ts | 15 +++++++++++++-- lib/content-agent/handlers/registerHandlers.ts | 16 ++++++++++++---- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/app/api/content-agent/[platform]/route.ts b/app/api/content-agent/[platform]/route.ts index d3f2dfc4..06554478 100644 --- a/app/api/content-agent/[platform]/route.ts +++ b/app/api/content-agent/[platform]/route.ts @@ -1,8 +1,8 @@ import type { NextRequest } from "next/server"; import { after } from "next/server"; -import { contentAgentBot } from "@/lib/content-agent/bot"; +import { getContentAgentBot } from "@/lib/content-agent/bot"; import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; -import "@/lib/content-agent/handlers/registerHandlers"; +import { ensureHandlersRegistered } from "@/lib/content-agent/handlers/registerHandlers"; /** * GET /api/content-agent/[platform] @@ -19,8 +19,10 @@ export async function GET( { params }: { params: Promise<{ platform: string }> }, ) { const { platform } = await params; + ensureHandlersRegistered(); + const bot = getContentAgentBot(); - const handler = contentAgentBot.webhooks[platform as keyof typeof contentAgentBot.webhooks]; + const handler = bot.webhooks[platform as keyof typeof bot.webhooks]; if (!handler) { return new Response("Unknown platform", { status: 404 }); @@ -51,9 +53,11 @@ export async function POST( if (verification) return verification; } - await contentAgentBot.initialize(); + ensureHandlersRegistered(); + const bot = getContentAgentBot(); + await bot.initialize(); - const handler = contentAgentBot.webhooks[platform as keyof typeof contentAgentBot.webhooks]; + const handler = bot.webhooks[platform as keyof typeof bot.webhooks]; if (!handler) { return new Response("Unknown platform", { status: 404 }); diff --git a/app/api/content-agent/callback/route.ts b/app/api/content-agent/callback/route.ts index 476af1e6..7e91ac4d 100644 --- a/app/api/content-agent/callback/route.ts +++ b/app/api/content-agent/callback/route.ts @@ -1,5 +1,5 @@ import type { NextRequest } from "next/server"; -import { contentAgentBot } from "@/lib/content-agent/bot"; +import { getContentAgentBot } from "@/lib/content-agent/bot"; import { handleContentAgentCallback } from "@/lib/content-agent/handleContentAgentCallback"; /** @@ -12,6 +12,6 @@ import { handleContentAgentCallback } from "@/lib/content-agent/handleContentAge * @returns The callback response */ export async function POST(request: NextRequest) { - await contentAgentBot.initialize(); + await getContentAgentBot().initialize(); return handleContentAgentCallback(request); } diff --git a/lib/content-agent/bot.ts b/lib/content-agent/bot.ts index 97db2599..743cf239 100644 --- a/lib/content-agent/bot.ts +++ b/lib/content-agent/bot.ts @@ -47,8 +47,18 @@ export function createContentAgentBot() { export type ContentAgentBot = ReturnType; +let _bot: ContentAgentBot | null = null; + /** - * Singleton bot instance. Registers as the Chat SDK singleton - * so ThreadImpl can resolve adapters lazily from thread IDs. + * Returns the lazily-initialized content agent bot singleton. + * Defers creation until first call so the Vercel build does not + * crash when content-agent env vars are not yet configured. + * + * @returns The content agent bot singleton */ -export const contentAgentBot = createContentAgentBot().registerSingleton(); +export function getContentAgentBot(): ContentAgentBot { + if (!_bot) { + _bot = createContentAgentBot().registerSingleton(); + } + return _bot; +} diff --git a/lib/content-agent/getThread.ts b/lib/content-agent/getThread.ts index 8aa75d55..38f87268 100644 --- a/lib/content-agent/getThread.ts +++ b/lib/content-agent/getThread.ts @@ -1,15 +1,26 @@ import { ThreadImpl } from "chat"; import type { ContentAgentThreadState } from "./types"; +const THREAD_ID_PATTERN = /^[^:]+:[^:]+:[^:]+$/; + /** * Reconstructs a Thread from a stored thread ID using the Chat SDK singleton. * * @param threadId - The stored thread identifier (format: adapter:channel:thread) * @returns The reconstructed Thread instance + * @throws If threadId does not match the expected adapter:channel:thread format */ export function getThread(threadId: string) { - const adapterName = threadId.split(":")[0]; - const channelId = `${adapterName}:${threadId.split(":")[1]}`; + if (!THREAD_ID_PATTERN.test(threadId)) { + throw new Error( + `[content-agent] Invalid threadId format: expected "adapter:channel:thread", got "${threadId}"`, + ); + } + + const parts = threadId.split(":"); + const adapterName = parts[0]; + const channelId = `${adapterName}:${parts[1]}`; + return new ThreadImpl({ adapterName, id: threadId, diff --git a/lib/content-agent/handlers/registerHandlers.ts b/lib/content-agent/handlers/registerHandlers.ts index 81657385..479ea0cc 100644 --- a/lib/content-agent/handlers/registerHandlers.ts +++ b/lib/content-agent/handlers/registerHandlers.ts @@ -1,10 +1,18 @@ -import { contentAgentBot } from "../bot"; +import { getContentAgentBot } from "../bot"; import { registerOnNewMention } from "./handleContentAgentMention"; import { registerOnSubscribedMessage } from "./registerOnSubscribedMessage"; +let registered = false; + /** * Registers all content agent event handlers on the bot singleton. - * Import this file once to attach handlers to the bot. + * Safe to call multiple times — handlers are only attached once. */ -registerOnNewMention(contentAgentBot); -registerOnSubscribedMessage(contentAgentBot); +export function ensureHandlersRegistered(): void { + if (registered) return; + registered = true; + + const bot = getContentAgentBot(); + registerOnNewMention(bot); + registerOnSubscribedMessage(bot); +} From 9da3aef8f893403b91e7cbcab8dae3478f936b35 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Wed, 25 Mar 2026 15:20:40 +0000 Subject: [PATCH 03/16] fix: graceful 503 when content-agent env vars missing - Add isContentAgentConfigured() check to all content-agent routes - Routes return 503 {"error": "Content agent not configured"} when env vars are not set, instead of crashing with 500 - Move x-callback-secret auth check to route level (runs before bot initialization) - Remove duplicate auth from handleContentAgentCallback handler Co-Authored-By: Paperclip --- app/api/content-agent/[platform]/route.ts | 9 +++++++ app/api/content-agent/callback/route.ts | 26 ++++++++++++++++++- .../handleContentAgentCallback.ts | 22 +++------------- lib/content-agent/validateEnv.ts | 9 +++++++ 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/app/api/content-agent/[platform]/route.ts b/app/api/content-agent/[platform]/route.ts index 06554478..28de8db1 100644 --- a/app/api/content-agent/[platform]/route.ts +++ b/app/api/content-agent/[platform]/route.ts @@ -3,6 +3,7 @@ import { after } from "next/server"; import { getContentAgentBot } from "@/lib/content-agent/bot"; import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; import { ensureHandlersRegistered } from "@/lib/content-agent/handlers/registerHandlers"; +import { isContentAgentConfigured } from "@/lib/content-agent/validateEnv"; /** * GET /api/content-agent/[platform] @@ -18,6 +19,10 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ platform: string }> }, ) { + if (!isContentAgentConfigured()) { + return Response.json({ error: "Content agent not configured" }, { status: 503 }); + } + const { platform } = await params; ensureHandlersRegistered(); const bot = getContentAgentBot(); @@ -53,6 +58,10 @@ export async function POST( if (verification) return verification; } + if (!isContentAgentConfigured()) { + return Response.json({ error: "Content agent not configured" }, { status: 503 }); + } + ensureHandlersRegistered(); const bot = getContentAgentBot(); await bot.initialize(); diff --git a/app/api/content-agent/callback/route.ts b/app/api/content-agent/callback/route.ts index 7e91ac4d..da861fe2 100644 --- a/app/api/content-agent/callback/route.ts +++ b/app/api/content-agent/callback/route.ts @@ -1,17 +1,41 @@ +import { timingSafeEqual } from "crypto"; import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getContentAgentBot } from "@/lib/content-agent/bot"; import { handleContentAgentCallback } from "@/lib/content-agent/handleContentAgentCallback"; +import { isContentAgentConfigured } from "@/lib/content-agent/validateEnv"; /** * POST /api/content-agent/callback * * Callback endpoint for the poll-content-run Trigger.dev task. - * Receives task results and posts them back to the Slack thread. + * Verifies the callback secret before initializing the bot, + * then delegates to the handler for body validation and processing. * * @param request - The incoming callback request * @returns The callback response */ export async function POST(request: NextRequest) { + const secret = request.headers.get("x-callback-secret"); + const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; + + if ( + !secret || + !expectedSecret || + secret.length !== expectedSecret.length || + !timingSafeEqual(Buffer.from(secret), Buffer.from(expectedSecret)) + ) { + return NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + if (!isContentAgentConfigured()) { + return Response.json({ error: "Content agent not configured" }, { status: 503 }); + } + await getContentAgentBot().initialize(); return handleContentAgentCallback(request); } diff --git a/lib/content-agent/handleContentAgentCallback.ts b/lib/content-agent/handleContentAgentCallback.ts index 936feb8f..3e80f569 100644 --- a/lib/content-agent/handleContentAgentCallback.ts +++ b/lib/content-agent/handleContentAgentCallback.ts @@ -1,32 +1,16 @@ -import { timingSafeEqual } from "crypto"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateContentAgentCallback } from "./validateContentAgentCallback"; import { getThread } from "./getThread"; /** - * Handles content agent task callback from the poll-content-run Trigger.dev task. - * Verifies the shared secret and posts results back to the Slack thread. + * Handles content agent task callback body parsing and processing. + * Auth (x-callback-secret) is verified by the route before this is called. * - * @param request - The incoming callback request + * @param request - The authenticated callback request * @returns A NextResponse */ export async function handleContentAgentCallback(request: Request): Promise { - const secret = request.headers.get("x-callback-secret"); - const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; - - if ( - !secret || - !expectedSecret || - secret.length !== expectedSecret.length || - !timingSafeEqual(Buffer.from(secret), Buffer.from(expectedSecret)) - ) { - return NextResponse.json( - { status: "error", error: "Unauthorized" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - let body: unknown; try { body = await request.json(); diff --git a/lib/content-agent/validateEnv.ts b/lib/content-agent/validateEnv.ts index 551b0bb4..3f6a4331 100644 --- a/lib/content-agent/validateEnv.ts +++ b/lib/content-agent/validateEnv.ts @@ -5,6 +5,15 @@ const REQUIRED_ENV_VARS = [ "REDIS_URL", ] as const; +/** + * Returns true if all required content agent environment variables are set. + * + * @returns Whether the content agent is fully configured + */ +export function isContentAgentConfigured(): boolean { + return REQUIRED_ENV_VARS.every(name => !!process.env[name]); +} + /** * Validates that all required environment variables for the content agent are set. * Throws an error listing all missing variables. From a93b0f0c90a5f9575e196f6367fc33ef675c8676 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Wed, 25 Mar 2026 15:39:05 +0000 Subject: [PATCH 04/16] refactor(content-agent): address CLEAN code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YAGNI: Remove unused /api/launch endpoint and lib/launch/ - SRP: Extract parseMentionArgs to its own file - SRP: Rename handleContentAgentMention.ts → registerOnNewMention.ts - DRY: Create shared createPlatformRoutes factory for agent webhook routes - DRY: Extract shared createAgentState for Redis/ioredis state setup - KISS: Move callback auth into handler to match coding-agent pattern - Restructure lib/content-agent/ → lib/agents/content/ Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/[platform]/route.ts | 60 ++---------- app/api/content-agent/[platform]/route.ts | 82 +++------------- app/api/content-agent/callback/route.ts | 27 +---- app/api/launch/route.ts | 51 ---------- lib/{content-agent => agents/content}/bot.ts | 21 +--- .../content}/getThread.ts | 0 .../content}/handleContentAgentCallback.ts | 22 ++++- .../content/handlers/parseMentionArgs.ts | 31 ++++++ .../content}/handlers/registerHandlers.ts | 2 +- .../content/handlers/registerOnNewMention.ts} | 36 +------ .../handlers/registerOnSubscribedMessage.ts | 0 .../content}/types.ts | 0 .../content}/validateContentAgentCallback.ts | 0 .../content}/validateEnv.ts | 0 lib/agents/createAgentState.ts | 29 ++++++ lib/agents/createPlatformRoutes.ts | 90 +++++++++++++++++ lib/coding-agent/bot.ts | 28 ++---- .../__tests__/validateLaunchBody.test.ts | 98 ------------------- lib/launch/buildCampaignPrompt.ts | 61 ------------ lib/launch/generateCampaignHandler.ts | 35 ------- lib/launch/validateLaunchBody.ts | 44 --------- 21 files changed, 206 insertions(+), 511 deletions(-) delete mode 100644 app/api/launch/route.ts rename lib/{content-agent => agents/content}/bot.ts (73%) rename lib/{content-agent => agents/content}/getThread.ts (100%) rename lib/{content-agent => agents/content}/handleContentAgentCallback.ts (76%) create mode 100644 lib/agents/content/handlers/parseMentionArgs.ts rename lib/{content-agent => agents/content}/handlers/registerHandlers.ts (87%) rename lib/{content-agent/handlers/handleContentAgentMention.ts => agents/content/handlers/registerOnNewMention.ts} (78%) rename lib/{content-agent => agents/content}/handlers/registerOnSubscribedMessage.ts (100%) rename lib/{content-agent => agents/content}/types.ts (100%) rename lib/{content-agent => agents/content}/validateContentAgentCallback.ts (100%) rename lib/{content-agent => agents/content}/validateEnv.ts (100%) create mode 100644 lib/agents/createAgentState.ts create mode 100644 lib/agents/createPlatformRoutes.ts delete mode 100644 lib/launch/__tests__/validateLaunchBody.test.ts delete mode 100644 lib/launch/buildCampaignPrompt.ts delete mode 100644 lib/launch/generateCampaignHandler.ts delete mode 100644 lib/launch/validateLaunchBody.ts diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index a51a2104..f58298ea 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -1,59 +1,13 @@ -import type { NextRequest } from "next/server"; -import { after } from "next/server"; +import { createPlatformRoutes } from "@/lib/agents/createPlatformRoutes"; import { codingAgentBot } from "@/lib/coding-agent/bot"; -import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; import "@/lib/coding-agent/handlers/registerHandlers"; /** - * GET /api/coding-agent/[platform] + * GET & POST /api/coding-agent/[platform] * - * Handles webhook verification handshakes (e.g. WhatsApp hub.challenge). - * - * @param request - The incoming verification request - * @param params - Route params containing the platform name - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ platform: string }> }, -) { - const { platform } = await params; - - const handler = codingAgentBot.webhooks[platform as keyof typeof codingAgentBot.webhooks]; - - if (!handler) { - return new Response("Unknown platform", { status: 404 }); - } - - return handler(request, { waitUntil: p => after(() => p) }); -} - -/** - * POST /api/coding-agent/[platform] - * - * Webhook endpoint for the coding agent bot. - * Handles Slack and WhatsApp webhooks via dynamic [platform] segment. - * - * @param request - The incoming webhook request - * @param params - Route params containing the platform name + * Webhook endpoints for the coding agent bot. + * Handles Slack, GitHub, and WhatsApp webhooks via dynamic [platform] segment. */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ platform: string }> }, -) { - const { platform } = await params; - - if (platform === "slack") { - const verification = await handleUrlVerification(request); - if (verification) return verification; - } - - await codingAgentBot.initialize(); - - const handler = codingAgentBot.webhooks[platform as keyof typeof codingAgentBot.webhooks]; - - if (!handler) { - return new Response("Unknown platform", { status: 404 }); - } - - return handler(request, { waitUntil: p => after(() => p) }); -} +export const { GET, POST } = createPlatformRoutes({ + getBot: () => codingAgentBot, +}); diff --git a/app/api/content-agent/[platform]/route.ts b/app/api/content-agent/[platform]/route.ts index 28de8db1..35eb37b9 100644 --- a/app/api/content-agent/[platform]/route.ts +++ b/app/api/content-agent/[platform]/route.ts @@ -1,76 +1,16 @@ -import type { NextRequest } from "next/server"; -import { after } from "next/server"; -import { getContentAgentBot } from "@/lib/content-agent/bot"; -import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; -import { ensureHandlersRegistered } from "@/lib/content-agent/handlers/registerHandlers"; -import { isContentAgentConfigured } from "@/lib/content-agent/validateEnv"; +import { createPlatformRoutes } from "@/lib/agents/createPlatformRoutes"; +import { getContentAgentBot } from "@/lib/agents/content/bot"; +import { ensureHandlersRegistered } from "@/lib/agents/content/handlers/registerHandlers"; +import { isContentAgentConfigured } from "@/lib/agents/content/validateEnv"; /** - * GET /api/content-agent/[platform] + * GET & POST /api/content-agent/[platform] * - * Handles webhook verification handshakes for the content agent bot. - * - * @param request - The incoming verification request - * @param params - Route params wrapper - * @param params.params - Promise resolving to the platform name - * @returns The webhook verification response - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ platform: string }> }, -) { - if (!isContentAgentConfigured()) { - return Response.json({ error: "Content agent not configured" }, { status: 503 }); - } - - const { platform } = await params; - ensureHandlersRegistered(); - const bot = getContentAgentBot(); - - const handler = bot.webhooks[platform as keyof typeof bot.webhooks]; - - if (!handler) { - return new Response("Unknown platform", { status: 404 }); - } - - return handler(request, { waitUntil: p => after(() => p) }); -} - -/** - * POST /api/content-agent/[platform] - * - * Webhook endpoint for the content agent bot. + * Webhook endpoints for the content agent bot. * Handles Slack webhooks via dynamic [platform] segment. - * - * @param request - The incoming webhook request - * @param params - Route params wrapper - * @param params.params - Promise resolving to the platform name - * @returns The webhook response */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ platform: string }> }, -) { - const { platform } = await params; - - if (platform === "slack") { - const verification = await handleUrlVerification(request); - if (verification) return verification; - } - - if (!isContentAgentConfigured()) { - return Response.json({ error: "Content agent not configured" }, { status: 503 }); - } - - ensureHandlersRegistered(); - const bot = getContentAgentBot(); - await bot.initialize(); - - const handler = bot.webhooks[platform as keyof typeof bot.webhooks]; - - if (!handler) { - return new Response("Unknown platform", { status: 404 }); - } - - return handler(request, { waitUntil: p => after(() => p) }); -} +export const { GET, POST } = createPlatformRoutes({ + getBot: getContentAgentBot, + ensureHandlers: ensureHandlersRegistered, + isConfigured: isContentAgentConfigured, +}); diff --git a/app/api/content-agent/callback/route.ts b/app/api/content-agent/callback/route.ts index da861fe2..2bead3f7 100644 --- a/app/api/content-agent/callback/route.ts +++ b/app/api/content-agent/callback/route.ts @@ -1,37 +1,18 @@ -import { timingSafeEqual } from "crypto"; import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getContentAgentBot } from "@/lib/content-agent/bot"; -import { handleContentAgentCallback } from "@/lib/content-agent/handleContentAgentCallback"; -import { isContentAgentConfigured } from "@/lib/content-agent/validateEnv"; +import { getContentAgentBot } from "@/lib/agents/content/bot"; +import { handleContentAgentCallback } from "@/lib/agents/content/handleContentAgentCallback"; +import { isContentAgentConfigured } from "@/lib/agents/content/validateEnv"; /** * POST /api/content-agent/callback * * Callback endpoint for the poll-content-run Trigger.dev task. - * Verifies the callback secret before initializing the bot, - * then delegates to the handler for body validation and processing. + * Receives task results and posts them back to the Slack thread. * * @param request - The incoming callback request * @returns The callback response */ export async function POST(request: NextRequest) { - const secret = request.headers.get("x-callback-secret"); - const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; - - if ( - !secret || - !expectedSecret || - secret.length !== expectedSecret.length || - !timingSafeEqual(Buffer.from(secret), Buffer.from(expectedSecret)) - ) { - return NextResponse.json( - { status: "error", error: "Unauthorized" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - if (!isContentAgentConfigured()) { return Response.json({ error: "Content agent not configured" }, { status: 503 }); } diff --git a/app/api/launch/route.ts b/app/api/launch/route.ts deleted file mode 100644 index 5c6ec554..00000000 --- a/app/api/launch/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { generateCampaignHandler } from "@/lib/launch/generateCampaignHandler"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns Empty 200 response with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/launch - * - * Streams an AI-generated music release campaign given artist and song details. - * Returns a text/plain stream with XML-style section markers that the client - * parses to render each campaign section in real-time. - * - * Authentication: x-api-key header OR Authorization: Bearer token required. - * - * Request body: - * - artist_name: string (required) — the artist's name - * - song_name: string (required) — the song or album name - * - genre: string (required) — musical genre - * - release_date: string (required) — release date (any format) - * - description: string (optional) — additional context for the AI - * - * Response: streaming text with section markers: - * [SECTION:press_release]...[/SECTION:press_release] - * [SECTION:spotify_pitch]...[/SECTION:spotify_pitch] - * [SECTION:instagram_captions]...[/SECTION:instagram_captions] - * [SECTION:tiktok_hooks]...[/SECTION:tiktok_hooks] - * [SECTION:fan_newsletter]...[/SECTION:fan_newsletter] - * [SECTION:curator_email]...[/SECTION:curator_email] - * - * @param request - The incoming request - * @returns Streaming text response or error - */ -export async function POST(request: NextRequest): Promise { - return generateCampaignHandler(request); -} - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; -export const revalidate = 0; diff --git a/lib/content-agent/bot.ts b/lib/agents/content/bot.ts similarity index 73% rename from lib/content-agent/bot.ts rename to lib/agents/content/bot.ts index 743cf239..ef9632a6 100644 --- a/lib/content-agent/bot.ts +++ b/lib/agents/content/bot.ts @@ -1,12 +1,9 @@ -import { Chat, ConsoleLogger } from "chat"; +import { Chat } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; -import { createIoRedisState } from "@chat-adapter/state-ioredis"; -import redis from "@/lib/redis/connection"; +import { agentLogger, createAgentState } from "@/lib/agents/createAgentState"; import type { ContentAgentThreadState } from "./types"; import { validateContentAgentEnv } from "./validateEnv"; -const logger = new ConsoleLogger(); - type ContentAgentAdapters = { slack: SlackAdapter; }; @@ -20,22 +17,12 @@ type ContentAgentAdapters = { export function createContentAgentBot() { validateContentAgentEnv(); - if (redis.status === "wait") { - redis.connect().catch(() => { - throw new Error("[content-agent] Redis failed to connect"); - }); - } - - const state = createIoRedisState({ - client: redis, - keyPrefix: "content-agent", - logger, - }); + const state = createAgentState("content-agent"); const slack = new SlackAdapter({ botToken: process.env.SLACK_CONTENT_BOT_TOKEN!, signingSecret: process.env.SLACK_CONTENT_SIGNING_SECRET!, - logger, + logger: agentLogger, }); return new Chat({ diff --git a/lib/content-agent/getThread.ts b/lib/agents/content/getThread.ts similarity index 100% rename from lib/content-agent/getThread.ts rename to lib/agents/content/getThread.ts diff --git a/lib/content-agent/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts similarity index 76% rename from lib/content-agent/handleContentAgentCallback.ts rename to lib/agents/content/handleContentAgentCallback.ts index 3e80f569..e1725ec6 100644 --- a/lib/content-agent/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -1,16 +1,32 @@ +import { timingSafeEqual } from "crypto"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateContentAgentCallback } from "./validateContentAgentCallback"; import { getThread } from "./getThread"; /** - * Handles content agent task callback body parsing and processing. - * Auth (x-callback-secret) is verified by the route before this is called. + * Handles content agent task callback from Trigger.dev. + * Verifies the shared secret and dispatches based on callback status. * - * @param request - The authenticated callback request + * @param request - The incoming callback request * @returns A NextResponse */ export async function handleContentAgentCallback(request: Request): Promise { + const secret = request.headers.get("x-callback-secret"); + const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; + + if ( + !secret || + !expectedSecret || + secret.length !== expectedSecret.length || + !timingSafeEqual(Buffer.from(secret), Buffer.from(expectedSecret)) + ) { + return NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + let body: unknown; try { body = await request.json(); diff --git a/lib/agents/content/handlers/parseMentionArgs.ts b/lib/agents/content/handlers/parseMentionArgs.ts new file mode 100644 index 00000000..66bbce44 --- /dev/null +++ b/lib/agents/content/handlers/parseMentionArgs.ts @@ -0,0 +1,31 @@ +import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; + +/** + * Parses the mention text into content generation parameters. + * + * Format: [template] [batch=N] [lipsync] + * + * @param text - The raw mention text to parse + * @returns Parsed content generation parameters + */ +export function parseMentionArgs(text: string) { + const tokens = text.trim().split(/\s+/); + const artistAccountId = tokens[0]; + let template = DEFAULT_CONTENT_TEMPLATE; + let batch = 1; + let lipsync = false; + + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i].toLowerCase(); + if (token.startsWith("batch=")) { + const n = parseInt(token.split("=")[1], 10); + if (!isNaN(n) && n >= 1 && n <= 30) batch = n; + } else if (token === "lipsync") { + lipsync = true; + } else if (!token.startsWith("batch") && token !== "lipsync") { + template = tokens[i]; // preserve original case for template name + } + } + + return { artistAccountId, template, batch, lipsync }; +} diff --git a/lib/content-agent/handlers/registerHandlers.ts b/lib/agents/content/handlers/registerHandlers.ts similarity index 87% rename from lib/content-agent/handlers/registerHandlers.ts rename to lib/agents/content/handlers/registerHandlers.ts index 479ea0cc..d56338f8 100644 --- a/lib/content-agent/handlers/registerHandlers.ts +++ b/lib/agents/content/handlers/registerHandlers.ts @@ -1,5 +1,5 @@ import { getContentAgentBot } from "../bot"; -import { registerOnNewMention } from "./handleContentAgentMention"; +import { registerOnNewMention } from "./registerOnNewMention"; import { registerOnSubscribedMessage } from "./registerOnSubscribedMessage"; let registered = false; diff --git a/lib/content-agent/handlers/handleContentAgentMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts similarity index 78% rename from lib/content-agent/handlers/handleContentAgentMention.ts rename to lib/agents/content/handlers/registerOnNewMention.ts index 81e4105e..49621abe 100644 --- a/lib/content-agent/handlers/handleContentAgentMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -4,40 +4,8 @@ import { triggerPollContentRun } from "@/lib/trigger/triggerPollContentRun"; import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; -import { - DEFAULT_CONTENT_TEMPLATE, - isSupportedContentTemplate, -} from "@/lib/content/contentTemplates"; - -/** - * Parses the mention text into content generation parameters. - * - * Format: [template] [batch=N] [lipsync] - * - * @param text - The raw mention text to parse - * @returns Parsed content generation parameters - */ -function parseMentionArgs(text: string) { - const tokens = text.trim().split(/\s+/); - const artistAccountId = tokens[0]; - let template = DEFAULT_CONTENT_TEMPLATE; - let batch = 1; - let lipsync = false; - - for (let i = 1; i < tokens.length; i++) { - const token = tokens[i].toLowerCase(); - if (token.startsWith("batch=")) { - const n = parseInt(token.split("=")[1], 10); - if (!isNaN(n) && n >= 1 && n <= 30) batch = n; - } else if (token === "lipsync") { - lipsync = true; - } else if (!token.startsWith("batch") && token !== "lipsync") { - template = tokens[i]; // preserve original case for template name - } - } - - return { artistAccountId, template, batch, lipsync }; -} +import { isSupportedContentTemplate } from "@/lib/content/contentTemplates"; +import { parseMentionArgs } from "./parseMentionArgs"; /** * Registers the onNewMention handler on the content agent bot. diff --git a/lib/content-agent/handlers/registerOnSubscribedMessage.ts b/lib/agents/content/handlers/registerOnSubscribedMessage.ts similarity index 100% rename from lib/content-agent/handlers/registerOnSubscribedMessage.ts rename to lib/agents/content/handlers/registerOnSubscribedMessage.ts diff --git a/lib/content-agent/types.ts b/lib/agents/content/types.ts similarity index 100% rename from lib/content-agent/types.ts rename to lib/agents/content/types.ts diff --git a/lib/content-agent/validateContentAgentCallback.ts b/lib/agents/content/validateContentAgentCallback.ts similarity index 100% rename from lib/content-agent/validateContentAgentCallback.ts rename to lib/agents/content/validateContentAgentCallback.ts diff --git a/lib/content-agent/validateEnv.ts b/lib/agents/content/validateEnv.ts similarity index 100% rename from lib/content-agent/validateEnv.ts rename to lib/agents/content/validateEnv.ts diff --git a/lib/agents/createAgentState.ts b/lib/agents/createAgentState.ts new file mode 100644 index 00000000..ab9d5b91 --- /dev/null +++ b/lib/agents/createAgentState.ts @@ -0,0 +1,29 @@ +import { ConsoleLogger } from "chat"; +import { createIoRedisState } from "@chat-adapter/state-ioredis"; +import redis from "@/lib/redis/connection"; + +/** + * Shared logger for all agent bots. + */ +export const agentLogger = new ConsoleLogger(); + +/** + * Creates a Redis-backed state adapter for an agent bot. + * Handles the Redis connection lifecycle and returns an ioredis state instance. + * + * @param keyPrefix - The Redis key prefix for this agent (e.g. "coding-agent", "content-agent") + * @returns The ioredis state adapter + */ +export function createAgentState(keyPrefix: string) { + if (redis.status === "wait") { + redis.connect().catch(() => { + throw new Error(`[${keyPrefix}] Redis failed to connect`); + }); + } + + return createIoRedisState({ + client: redis, + keyPrefix, + logger: agentLogger, + }); +} diff --git a/lib/agents/createPlatformRoutes.ts b/lib/agents/createPlatformRoutes.ts new file mode 100644 index 00000000..9a9cf2ad --- /dev/null +++ b/lib/agents/createPlatformRoutes.ts @@ -0,0 +1,90 @@ +import type { NextRequest } from "next/server"; +import { after } from "next/server"; +import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; + +type WebhookHandler = ( + request: Request, + options?: { waitUntil?: (task: Promise) => void }, +) => Promise; + +interface AgentBotLike { + webhooks: Record; + initialize(): Promise; +} + +interface PlatformRouteConfig { + getBot: () => AgentBotLike; + ensureHandlers?: () => void; + isConfigured?: () => boolean; +} + +/** + * Creates GET and POST route handlers for a [platform] webhook route. + * Shared across agent bots (coding-agent, content-agent) to avoid duplication. + * + * @param config - Bot accessor, optional handler registration, optional env guard + * @returns GET and POST route handlers for Next.js App Router + */ +export function createPlatformRoutes(config: PlatformRouteConfig) { + /** + * Handles webhook verification handshakes (e.g. WhatsApp hub.challenge). + * + * @param request - The incoming verification request + * @param root0 - Route params wrapper + * @param root0.params - Promise resolving to the platform name + * @returns The webhook verification response + */ + async function GET(request: NextRequest, { params }: { params: Promise<{ platform: string }> }) { + if (config.isConfigured && !config.isConfigured()) { + return Response.json({ error: "Agent not configured" }, { status: 503 }); + } + + const { platform } = await params; + config.ensureHandlers?.(); + + const bot = config.getBot(); + const handler = bot.webhooks[platform]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); + } + + /** + * Handles incoming webhook events from the platform adapter. + * + * @param request - The incoming webhook request + * @param root0 - Route params wrapper + * @param root0.params - Promise resolving to the platform name + * @returns The webhook response + */ + async function POST(request: NextRequest, { params }: { params: Promise<{ platform: string }> }) { + const { platform } = await params; + + if (platform === "slack") { + const verification = await handleUrlVerification(request); + if (verification) return verification; + } + + if (config.isConfigured && !config.isConfigured()) { + return Response.json({ error: "Agent not configured" }, { status: 503 }); + } + + config.ensureHandlers?.(); + + const bot = config.getBot(); + await bot.initialize(); + + const handler = bot.webhooks[platform]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); + } + + return { GET, POST }; +} diff --git a/lib/coding-agent/bot.ts b/lib/coding-agent/bot.ts index 88160c5c..273ca613 100644 --- a/lib/coding-agent/bot.ts +++ b/lib/coding-agent/bot.ts @@ -1,15 +1,12 @@ -import { Chat, ConsoleLogger } from "chat"; +import { Chat } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; import { createWhatsAppAdapter, WhatsAppAdapter } from "@chat-adapter/whatsapp"; import { createGitHubAdapter } from "@chat-adapter/github"; -import { createIoRedisState } from "@chat-adapter/state-ioredis"; -import redis from "@/lib/redis/connection"; +import { agentLogger, createAgentState } from "@/lib/agents/createAgentState"; import type { CodingAgentThreadState } from "./types"; import { validateCodingAgentEnv } from "./validateEnv"; import { isWhatsAppConfigured } from "./whatsApp/isWhatsAppConfigured"; -const logger = new ConsoleLogger(); - type CodingAgentAdapters = { slack: SlackAdapter; github: ReturnType; @@ -18,40 +15,31 @@ type CodingAgentAdapters = { /** * Creates a new Chat bot instance configured with Slack, GitHub, and optionally WhatsApp adapters. + * + * @returns The configured Chat bot instance */ export function createCodingAgentBot() { validateCodingAgentEnv(); - // ioredis is configured with lazyConnect: true, so we must - // explicitly connect before the state adapter listens for "ready". - if (redis.status === "wait") { - redis.connect().catch(() => { - throw new Error("[coding-agent] Redis failed to connect"); - }); - } - const state = createIoRedisState({ - client: redis, - keyPrefix: "coding-agent", - logger, - }); + const state = createAgentState("coding-agent"); const slack = new SlackAdapter({ botToken: process.env.SLACK_BOT_TOKEN!, signingSecret: process.env.SLACK_SIGNING_SECRET!, - logger, + logger: agentLogger, }); const github = createGitHubAdapter({ token: process.env.GITHUB_TOKEN!, webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!, userName: process.env.GITHUB_BOT_USERNAME ?? "recoup-coding-agent", - logger, + logger: agentLogger, }); const adapters: CodingAgentAdapters = { slack, github }; if (isWhatsAppConfigured()) { - adapters.whatsapp = createWhatsAppAdapter({ logger }); + adapters.whatsapp = createWhatsAppAdapter({ logger: agentLogger }); } return new Chat({ diff --git a/lib/launch/__tests__/validateLaunchBody.test.ts b/lib/launch/__tests__/validateLaunchBody.test.ts deleted file mode 100644 index 378ea301..00000000 --- a/lib/launch/__tests__/validateLaunchBody.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; -import { validateLaunchBody } from "../validateLaunchBody"; - -/** - * Remove a key from an object — test utility. - * - * @param obj - Source object - * @param key - Key to remove - * @returns Object without the specified key - */ -function omit(obj: T, key: K): Omit { - const copy = { ...obj }; - delete copy[key]; - return copy; -} - -describe("validateLaunchBody", () => { - const validBody = { - artist_name: "Gliiico", - song_name: "Midnight Drive", - genre: "Indie Pop", - release_date: "2026-04-01", - description: "A song about late night drives and nostalgia", - }; - - describe("successful cases", () => { - it("returns parsed body when all required fields are present", () => { - const result = validateLaunchBody(validBody); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.artist_name).toBe("Gliiico"); - expect(result.song_name).toBe("Midnight Drive"); - expect(result.genre).toBe("Indie Pop"); - expect(result.release_date).toBe("2026-04-01"); - expect(result.description).toBe("A song about late night drives and nostalgia"); - } - }); - - it("accepts body without optional description", () => { - const result = validateLaunchBody(omit(validBody, "description")); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.description).toBeUndefined(); - } - }); - }); - - describe("error cases", () => { - it("returns 400 when artist_name is missing", () => { - const result = validateLaunchBody(omit(validBody, "artist_name")); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when song_name is missing", () => { - const result = validateLaunchBody(omit(validBody, "song_name")); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when genre is missing", () => { - const result = validateLaunchBody(omit(validBody, "genre")); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when release_date is missing", () => { - const result = validateLaunchBody(omit(validBody, "release_date")); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when artist_name is empty string", () => { - const result = validateLaunchBody({ ...validBody, artist_name: "" }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when body is null", () => { - const result = validateLaunchBody(null); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - }); -}); diff --git a/lib/launch/buildCampaignPrompt.ts b/lib/launch/buildCampaignPrompt.ts deleted file mode 100644 index f14e35ce..00000000 --- a/lib/launch/buildCampaignPrompt.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { LaunchBody } from "./validateLaunchBody"; - -/** - * Builds the system prompt for the release campaign generator. - * - * @returns The system prompt string - */ -export function buildCampaignSystemPrompt(): string { - return `You are an expert music industry publicist and marketing strategist with 15+ years of experience -launching indie and major label artists. You write compelling, professional, and authentic music PR content -that sounds human — never generic. Your press releases get picked up by music blogs. Your Spotify pitches -get playlisted. Your emails get replies. - -Always write as if this artist and song are genuinely exciting. Use vivid, specific language. -Avoid clichés like "sonic journey" or "genre-defying." -Output each section EXACTLY as instructed with the section markers provided — no extra text outside the markers.`; -} - -/** - * Builds the user prompt for the release campaign generator. - * - * @param body - Validated launch request body - * @returns The formatted user prompt - */ -export function buildCampaignUserPrompt(body: LaunchBody): string { - const { artist_name, song_name, genre, release_date, description } = body; - - const context = description ? `\nAdditional context: ${description}` : ""; - - return `Generate a complete music release campaign for: -Artist: ${artist_name} -Song/Release: ${song_name} -Genre: ${genre} -Release Date: ${release_date}${context} - -Generate each section IN ORDER using EXACTLY these markers (do not skip or reorder): - -[SECTION:press_release] -Write a professional 2-3 paragraph press release announcing the release. Include a punchy headline, vivid description of the song's sound, the artist's backstory in 1 sentence, and a quote from the artist. End with release date and where to stream. -[/SECTION:press_release] - -[SECTION:spotify_pitch] -Write a compelling Spotify editorial pitch (200-250 words). Describe the song's sonic DNA, emotional core, lyrical themes, production style, and which playlists it fits. Include 3 specific Spotify playlist names this would fit. -[/SECTION:spotify_pitch] - -[SECTION:instagram_captions] -Write 5 different Instagram captions for the release announcement post. Vary the tone: one hype, one personal/vulnerable, one cryptic/teaser, one funny, one simple & clean. Include 5-8 relevant hashtags for each. -[/SECTION:instagram_captions] - -[SECTION:tiktok_hooks] -Write 5 different TikTok video hook scripts. Each hook is the first 3 seconds of a video — punchy, scroll-stopping. Format as: "Hook [N]: [script]". Make them specific to the song's vibe. -[/SECTION:tiktok_hooks] - -[SECTION:fan_newsletter] -Write an email newsletter to fans announcing the release. Start with a compelling subject line on its own line (format: "Subject: [subject]"), then the email body. Make it personal, like the artist is writing directly to fans. -[/SECTION:fan_newsletter] - -[SECTION:curator_email] -Write a cold email to a playlist curator pitching the song for placement. Keep it under 150 words. Subject line first (format: "Subject: [subject]"), then body. Be specific, not salesy. Include the Spotify link placeholder [SPOTIFY_LINK]. -[/SECTION:curator_email]`; -} diff --git a/lib/launch/generateCampaignHandler.ts b/lib/launch/generateCampaignHandler.ts deleted file mode 100644 index eef2f922..00000000 --- a/lib/launch/generateCampaignHandler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { streamText } from "ai"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateLaunchBody } from "./validateLaunchBody"; -import { buildCampaignSystemPrompt, buildCampaignUserPrompt } from "./buildCampaignPrompt"; -import { DEFAULT_MODEL } from "@/lib/const"; - -/** - * Handles POST /api/launch — streams an AI-generated release campaign. - * - * @param request - The incoming request - * @returns A streaming text response containing the campaign sections - */ -export async function generateCampaignHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const json = await request.json(); - const validated = validateLaunchBody(json); - if (validated instanceof NextResponse) { - return validated; - } - - const result = streamText({ - model: DEFAULT_MODEL, - system: buildCampaignSystemPrompt(), - prompt: buildCampaignUserPrompt(validated), - }); - - return result.toTextStreamResponse({ headers: getCorsHeaders() }); -} diff --git a/lib/launch/validateLaunchBody.ts b/lib/launch/validateLaunchBody.ts deleted file mode 100644 index ea67ab8a..00000000 --- a/lib/launch/validateLaunchBody.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const launchBodySchema = z.object({ - artist_name: z - .string({ message: "artist_name is required" }) - .min(1, "artist_name cannot be empty"), - song_name: z.string({ message: "song_name is required" }).min(1, "song_name cannot be empty"), - genre: z.string({ message: "genre is required" }).min(1, "genre cannot be empty"), - release_date: z - .string({ message: "release_date is required" }) - .min(1, "release_date cannot be empty"), - description: z.string().optional(), -}); - -export type LaunchBody = z.infer; - -/** - * Validates request body for POST /api/launch. - * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. - */ -export function validateLaunchBody(body: unknown): NextResponse | LaunchBody { - const result = launchBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} From 2abed882dfb6fcfd088395cad9c829dd0698ed0c Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Wed, 25 Mar 2026 16:11:44 +0000 Subject: [PATCH 05/16] refactor(content-agent): address round 2 review feedback SRP: Split validateEnv.ts into isContentAgentConfigured.ts and validateContentAgentEnv.ts (one export per file). KISS: Refactor bot.ts to follow coding-agent eager singleton pattern (contentAgentBot variable instead of getContentAgentBot function). KISS: Refactor registerHandlers.ts to use module-level side-effect registration matching coding-agent pattern (removed registered flag). DRY: Extract shared getThread to lib/agents/getThread.ts, used by both content-agent and coding-agent. CodeRabbit: Add Zod platform param validation and consistent JSON error responses in createPlatformRoutes.ts. Co-Authored-By: Paperclip --- app/api/content-agent/[platform]/route.ts | 10 ++++---- app/api/content-agent/callback/route.ts | 7 +++--- lib/agents/content/bot.ts | 23 +++++++------------ .../content/handleContentAgentCallback.ts | 5 ++-- .../content/handlers/registerHandlers.ts | 16 ++++--------- .../content/isContentAgentConfigured.ts | 10 ++++++++ ...idateEnv.ts => validateContentAgentEnv.ts} | 13 ++--------- lib/agents/createPlatformRoutes.ts | 23 +++++++++++++++---- lib/agents/{content => }/getThread.ts | 8 +++---- lib/coding-agent/getThread.ts | 10 ++------ 10 files changed, 60 insertions(+), 65 deletions(-) create mode 100644 lib/agents/content/isContentAgentConfigured.ts rename lib/agents/content/{validateEnv.ts => validateContentAgentEnv.ts} (57%) rename lib/agents/{content => }/getThread.ts (71%) diff --git a/app/api/content-agent/[platform]/route.ts b/app/api/content-agent/[platform]/route.ts index 35eb37b9..0e982157 100644 --- a/app/api/content-agent/[platform]/route.ts +++ b/app/api/content-agent/[platform]/route.ts @@ -1,7 +1,6 @@ import { createPlatformRoutes } from "@/lib/agents/createPlatformRoutes"; -import { getContentAgentBot } from "@/lib/agents/content/bot"; -import { ensureHandlersRegistered } from "@/lib/agents/content/handlers/registerHandlers"; -import { isContentAgentConfigured } from "@/lib/agents/content/validateEnv"; +import { contentAgentBot } from "@/lib/agents/content/bot"; +import "@/lib/agents/content/handlers/registerHandlers"; /** * GET & POST /api/content-agent/[platform] @@ -10,7 +9,6 @@ import { isContentAgentConfigured } from "@/lib/agents/content/validateEnv"; * Handles Slack webhooks via dynamic [platform] segment. */ export const { GET, POST } = createPlatformRoutes({ - getBot: getContentAgentBot, - ensureHandlers: ensureHandlersRegistered, - isConfigured: isContentAgentConfigured, + getBot: () => contentAgentBot!, + isConfigured: () => contentAgentBot !== null, }); diff --git a/app/api/content-agent/callback/route.ts b/app/api/content-agent/callback/route.ts index 2bead3f7..3c1c0429 100644 --- a/app/api/content-agent/callback/route.ts +++ b/app/api/content-agent/callback/route.ts @@ -1,7 +1,6 @@ import type { NextRequest } from "next/server"; -import { getContentAgentBot } from "@/lib/agents/content/bot"; +import { contentAgentBot } from "@/lib/agents/content/bot"; import { handleContentAgentCallback } from "@/lib/agents/content/handleContentAgentCallback"; -import { isContentAgentConfigured } from "@/lib/agents/content/validateEnv"; /** * POST /api/content-agent/callback @@ -13,10 +12,10 @@ import { isContentAgentConfigured } from "@/lib/agents/content/validateEnv"; * @returns The callback response */ export async function POST(request: NextRequest) { - if (!isContentAgentConfigured()) { + if (!contentAgentBot) { return Response.json({ error: "Content agent not configured" }, { status: 503 }); } - await getContentAgentBot().initialize(); + await contentAgentBot.initialize(); return handleContentAgentCallback(request); } diff --git a/lib/agents/content/bot.ts b/lib/agents/content/bot.ts index ef9632a6..70f88ba4 100644 --- a/lib/agents/content/bot.ts +++ b/lib/agents/content/bot.ts @@ -2,7 +2,8 @@ import { Chat } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; import { agentLogger, createAgentState } from "@/lib/agents/createAgentState"; import type { ContentAgentThreadState } from "./types"; -import { validateContentAgentEnv } from "./validateEnv"; +import { isContentAgentConfigured } from "./isContentAgentConfigured"; +import { validateContentAgentEnv } from "./validateContentAgentEnv"; type ContentAgentAdapters = { slack: SlackAdapter; @@ -14,7 +15,7 @@ type ContentAgentAdapters = { * * @returns The configured Chat bot instance */ -export function createContentAgentBot() { +function createContentAgentBot() { validateContentAgentEnv(); const state = createAgentState("content-agent"); @@ -34,18 +35,10 @@ export function createContentAgentBot() { export type ContentAgentBot = ReturnType; -let _bot: ContentAgentBot | null = null; - /** - * Returns the lazily-initialized content agent bot singleton. - * Defers creation until first call so the Vercel build does not - * crash when content-agent env vars are not yet configured. - * - * @returns The content agent bot singleton + * Singleton bot instance. Only created when content agent env vars are configured. + * Registers as the Chat SDK singleton so ThreadImpl can resolve adapters lazily from thread IDs. */ -export function getContentAgentBot(): ContentAgentBot { - if (!_bot) { - _bot = createContentAgentBot().registerSingleton(); - } - return _bot; -} +export const contentAgentBot: ContentAgentBot | null = isContentAgentConfigured() + ? createContentAgentBot().registerSingleton() + : null; diff --git a/lib/agents/content/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts index e1725ec6..435e5225 100644 --- a/lib/agents/content/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -2,7 +2,8 @@ import { timingSafeEqual } from "crypto"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateContentAgentCallback } from "./validateContentAgentCallback"; -import { getThread } from "./getThread"; +import { getThread } from "@/lib/agents/getThread"; +import type { ContentAgentThreadState } from "./types"; /** * Handles content agent task callback from Trigger.dev. @@ -43,7 +44,7 @@ export async function handleContentAgentCallback(request: Request): Promise(validated.threadId); switch (validated.status) { case "completed": { diff --git a/lib/agents/content/handlers/registerHandlers.ts b/lib/agents/content/handlers/registerHandlers.ts index d56338f8..5bc537d9 100644 --- a/lib/agents/content/handlers/registerHandlers.ts +++ b/lib/agents/content/handlers/registerHandlers.ts @@ -1,18 +1,12 @@ -import { getContentAgentBot } from "../bot"; +import { contentAgentBot } from "../bot"; import { registerOnNewMention } from "./registerOnNewMention"; import { registerOnSubscribedMessage } from "./registerOnSubscribedMessage"; -let registered = false; - /** * Registers all content agent event handlers on the bot singleton. - * Safe to call multiple times — handlers are only attached once. + * Import this file once to attach handlers to the bot. */ -export function ensureHandlersRegistered(): void { - if (registered) return; - registered = true; - - const bot = getContentAgentBot(); - registerOnNewMention(bot); - registerOnSubscribedMessage(bot); +if (contentAgentBot) { + registerOnNewMention(contentAgentBot); + registerOnSubscribedMessage(contentAgentBot); } diff --git a/lib/agents/content/isContentAgentConfigured.ts b/lib/agents/content/isContentAgentConfigured.ts new file mode 100644 index 00000000..b0fc2748 --- /dev/null +++ b/lib/agents/content/isContentAgentConfigured.ts @@ -0,0 +1,10 @@ +import { CONTENT_AGENT_REQUIRED_ENV_VARS } from "./validateContentAgentEnv"; + +/** + * Returns true if all required content agent environment variables are set. + * + * @returns Whether the content agent is fully configured + */ +export function isContentAgentConfigured(): boolean { + return CONTENT_AGENT_REQUIRED_ENV_VARS.every(name => !!process.env[name]); +} diff --git a/lib/agents/content/validateEnv.ts b/lib/agents/content/validateContentAgentEnv.ts similarity index 57% rename from lib/agents/content/validateEnv.ts rename to lib/agents/content/validateContentAgentEnv.ts index 3f6a4331..bc72ff23 100644 --- a/lib/agents/content/validateEnv.ts +++ b/lib/agents/content/validateContentAgentEnv.ts @@ -1,25 +1,16 @@ -const REQUIRED_ENV_VARS = [ +export const CONTENT_AGENT_REQUIRED_ENV_VARS = [ "SLACK_CONTENT_BOT_TOKEN", "SLACK_CONTENT_SIGNING_SECRET", "CONTENT_AGENT_CALLBACK_SECRET", "REDIS_URL", ] as const; -/** - * Returns true if all required content agent environment variables are set. - * - * @returns Whether the content agent is fully configured - */ -export function isContentAgentConfigured(): boolean { - return REQUIRED_ENV_VARS.every(name => !!process.env[name]); -} - /** * Validates that all required environment variables for the content agent are set. * Throws an error listing all missing variables. */ export function validateContentAgentEnv(): void { - const missing = REQUIRED_ENV_VARS.filter(name => !process.env[name]); + const missing = CONTENT_AGENT_REQUIRED_ENV_VARS.filter(name => !process.env[name]); if (missing.length > 0) { throw new Error( `[content-agent] Missing required environment variables:\n${missing.map(v => ` - ${v}`).join("\n")}`, diff --git a/lib/agents/createPlatformRoutes.ts b/lib/agents/createPlatformRoutes.ts index 9a9cf2ad..5ee7420b 100644 --- a/lib/agents/createPlatformRoutes.ts +++ b/lib/agents/createPlatformRoutes.ts @@ -1,7 +1,12 @@ import type { NextRequest } from "next/server"; import { after } from "next/server"; +import { z } from "zod"; import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; +const platformSchema = z.object({ + platform: z.string().min(1), +}); + type WebhookHandler = ( request: Request, options?: { waitUntil?: (task: Promise) => void }, @@ -39,14 +44,19 @@ export function createPlatformRoutes(config: PlatformRouteConfig) { return Response.json({ error: "Agent not configured" }, { status: 503 }); } - const { platform } = await params; + const parsed = platformSchema.safeParse(await params); + if (!parsed.success) { + return Response.json({ error: "Invalid platform parameter" }, { status: 400 }); + } + + const { platform } = parsed.data; config.ensureHandlers?.(); const bot = config.getBot(); const handler = bot.webhooks[platform]; if (!handler) { - return new Response("Unknown platform", { status: 404 }); + return Response.json({ error: "Unknown platform" }, { status: 404 }); } return handler(request, { waitUntil: p => after(() => p) }); @@ -61,7 +71,12 @@ export function createPlatformRoutes(config: PlatformRouteConfig) { * @returns The webhook response */ async function POST(request: NextRequest, { params }: { params: Promise<{ platform: string }> }) { - const { platform } = await params; + const parsed = platformSchema.safeParse(await params); + if (!parsed.success) { + return Response.json({ error: "Invalid platform parameter" }, { status: 400 }); + } + + const { platform } = parsed.data; if (platform === "slack") { const verification = await handleUrlVerification(request); @@ -80,7 +95,7 @@ export function createPlatformRoutes(config: PlatformRouteConfig) { const handler = bot.webhooks[platform]; if (!handler) { - return new Response("Unknown platform", { status: 404 }); + return Response.json({ error: "Unknown platform" }, { status: 404 }); } return handler(request, { waitUntil: p => after(() => p) }); diff --git a/lib/agents/content/getThread.ts b/lib/agents/getThread.ts similarity index 71% rename from lib/agents/content/getThread.ts rename to lib/agents/getThread.ts index 38f87268..8a306dcb 100644 --- a/lib/agents/content/getThread.ts +++ b/lib/agents/getThread.ts @@ -1,19 +1,19 @@ import { ThreadImpl } from "chat"; -import type { ContentAgentThreadState } from "./types"; const THREAD_ID_PATTERN = /^[^:]+:[^:]+:[^:]+$/; /** * Reconstructs a Thread from a stored thread ID using the Chat SDK singleton. + * Shared across agent bots (coding-agent, content-agent). * * @param threadId - The stored thread identifier (format: adapter:channel:thread) * @returns The reconstructed Thread instance * @throws If threadId does not match the expected adapter:channel:thread format */ -export function getThread(threadId: string) { +export function getThread>(threadId: string) { if (!THREAD_ID_PATTERN.test(threadId)) { throw new Error( - `[content-agent] Invalid threadId format: expected "adapter:channel:thread", got "${threadId}"`, + `Invalid threadId format: expected "adapter:channel:thread", got "${threadId}"`, ); } @@ -21,7 +21,7 @@ export function getThread(threadId: string) { const adapterName = parts[0]; const channelId = `${adapterName}:${parts[1]}`; - return new ThreadImpl({ + return new ThreadImpl({ adapterName, id: threadId, channelId, diff --git a/lib/coding-agent/getThread.ts b/lib/coding-agent/getThread.ts index 1322db4d..5cf37231 100644 --- a/lib/coding-agent/getThread.ts +++ b/lib/coding-agent/getThread.ts @@ -1,4 +1,4 @@ -import { ThreadImpl } from "chat"; +import { getThread as getAgentThread } from "@/lib/agents/getThread"; import type { CodingAgentThreadState } from "./types"; /** @@ -7,11 +7,5 @@ import type { CodingAgentThreadState } from "./types"; * @param threadId */ export function getThread(threadId: string) { - const adapterName = threadId.split(":")[0]; - const channelId = `${adapterName}:${threadId.split(":")[1]}`; - return new ThreadImpl({ - adapterName, - id: threadId, - channelId, - }); + return getAgentThread(threadId); } From efd3b1bae06d9d37484ca8916cb961be0aee5907 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Wed, 25 Mar 2026 16:21:44 +0000 Subject: [PATCH 06/16] fix(content-agent): address round 3 CodeRabbit review feedback - Fix unhandled promise rejection in createAgentState (log instead of throw in .catch) - Fix timingSafeEqual byte-length comparison in callback auth - Add idempotency guard in callback handler (skip if thread not running) - Add threadId format validation regex in Zod schema - Reset thread state to failed on triggerPollContentRun failure - Guard against bot echo loops in onSubscribedMessage handler Co-Authored-By: Paperclip --- lib/agents/content/handleContentAgentCallback.ts | 13 +++++++++++-- .../content/handlers/registerOnNewMention.ts | 16 ++++++++++++---- .../handlers/registerOnSubscribedMessage.ts | 5 ++++- .../content/validateContentAgentCallback.ts | 5 ++++- lib/agents/createAgentState.ts | 4 ++-- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/agents/content/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts index 435e5225..bfc3579d 100644 --- a/lib/agents/content/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -16,11 +16,14 @@ export async function handleContentAgentCallback(request: Request): Promise(validated.threadId); + // Idempotency: skip if thread is no longer running (duplicate/retry delivery) + const currentState = await thread.state; + if (currentState?.status && currentState.status !== "running") { + return NextResponse.json({ status: "ok", skipped: true }, { headers: getCorsHeaders() }); + } + switch (validated.status) { case "completed": { const results = validated.results ?? []; diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 49621abe..0187a9f0 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -102,12 +102,20 @@ export function registerOnNewMention(bot: ContentAgentBot) { }); // Trigger polling task - await triggerPollContentRun({ - runIds, - callbackThreadId: thread.id, - }); + try { + await triggerPollContentRun({ + runIds, + callbackThreadId: thread.id, + }); + } catch (pollError) { + console.error("[content-agent] triggerPollContentRun failed:", pollError); + await thread.setState({ status: "failed" }); + await thread.post("Failed to start content polling. Please try again."); + return; + } } catch (error) { console.error("[content-agent] onNewMention error:", error); + await thread.setState({ status: "failed" }); await thread.post("Something went wrong starting content generation. Please try again."); } }); diff --git a/lib/agents/content/handlers/registerOnSubscribedMessage.ts b/lib/agents/content/handlers/registerOnSubscribedMessage.ts index df89bbeb..5371a24b 100644 --- a/lib/agents/content/handlers/registerOnSubscribedMessage.ts +++ b/lib/agents/content/handlers/registerOnSubscribedMessage.ts @@ -7,7 +7,10 @@ import type { ContentAgentBot } from "../bot"; * @param bot - The content agent bot instance to register the handler on */ export function registerOnSubscribedMessage(bot: ContentAgentBot) { - bot.onSubscribedMessage(async (thread, _) => { + bot.onSubscribedMessage(async (thread, message) => { + // Guard against bot-authored messages to prevent echo loops + if (message.author.isBot || message.author.isMe) return; + const state = await thread.state; if (state?.status === "running") { diff --git a/lib/agents/content/validateContentAgentCallback.ts b/lib/agents/content/validateContentAgentCallback.ts index 73fd6d9c..70fff8f9 100644 --- a/lib/agents/content/validateContentAgentCallback.ts +++ b/lib/agents/content/validateContentAgentCallback.ts @@ -11,7 +11,10 @@ const contentRunResultSchema = z.object({ }); export const contentAgentCallbackSchema = z.object({ - threadId: z.string({ message: "threadId is required" }).min(1, "threadId cannot be empty"), + threadId: z + .string({ message: "threadId is required" }) + .min(1, "threadId cannot be empty") + .regex(/^[^:]+:[^:]+:[^:]+$/, "threadId must match adapter:channel:thread format"), status: z.enum(["completed", "failed", "timeout"]), results: z.array(contentRunResultSchema).optional(), message: z.string().optional(), diff --git a/lib/agents/createAgentState.ts b/lib/agents/createAgentState.ts index ab9d5b91..124aaaf1 100644 --- a/lib/agents/createAgentState.ts +++ b/lib/agents/createAgentState.ts @@ -16,8 +16,8 @@ export const agentLogger = new ConsoleLogger(); */ export function createAgentState(keyPrefix: string) { if (redis.status === "wait") { - redis.connect().catch(() => { - throw new Error(`[${keyPrefix}] Redis failed to connect`); + redis.connect().catch(err => { + console.error(`[${keyPrefix}] Redis failed to connect:`, err); }); } From 5d7a59a8fc72c077198885e979775cd1a9f4f773 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 11:59:06 -0500 Subject: [PATCH 07/16] refactor: use CODING_AGENT_CALLBACK_SECRET instead of CONTENT_AGENT_CALLBACK_SECRET Reuses the existing coding agent callback secret env var so we don't need to configure a separate secret for the content agent. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/handleContentAgentCallback.ts | 2 +- lib/agents/content/validateContentAgentEnv.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/agents/content/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts index bfc3579d..de01f754 100644 --- a/lib/agents/content/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -14,7 +14,7 @@ import type { ContentAgentThreadState } from "./types"; */ export async function handleContentAgentCallback(request: Request): Promise { const secret = request.headers.get("x-callback-secret"); - const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; + const expectedSecret = process.env.CODING_AGENT_CALLBACK_SECRET; const secretBuf = secret ? Buffer.from(secret) : Buffer.alloc(0); const expectedBuf = expectedSecret ? Buffer.from(expectedSecret) : Buffer.alloc(0); diff --git a/lib/agents/content/validateContentAgentEnv.ts b/lib/agents/content/validateContentAgentEnv.ts index bc72ff23..d6693626 100644 --- a/lib/agents/content/validateContentAgentEnv.ts +++ b/lib/agents/content/validateContentAgentEnv.ts @@ -1,7 +1,7 @@ export const CONTENT_AGENT_REQUIRED_ENV_VARS = [ "SLACK_CONTENT_BOT_TOKEN", "SLACK_CONTENT_SIGNING_SECRET", - "CONTENT_AGENT_CALLBACK_SECRET", + "CODING_AGENT_CALLBACK_SECRET", "REDIS_URL", ] as const; From fe28e716a2881e64f1a0b87799906f4c4a68c893 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 12:00:24 -0500 Subject: [PATCH 08/16] test: add tests for content agent env validation and callback auth Verifies that: - CODING_AGENT_CALLBACK_SECRET is used (not CONTENT_AGENT_CALLBACK_SECRET) - validateContentAgentEnv throws when env vars are missing - isContentAgentConfigured returns false when env vars are missing - handleContentAgentCallback rejects invalid/missing secrets Co-Authored-By: Claude Opus 4.6 (1M context) --- .../handleContentAgentCallback.test.ts | 73 +++++++++++++++++++ .../isContentAgentConfigured.test.ts | 26 +++++++ .../__tests__/validateContentAgentEnv.test.ts | 42 +++++++++++ 3 files changed, 141 insertions(+) create mode 100644 lib/agents/content/__tests__/handleContentAgentCallback.test.ts create mode 100644 lib/agents/content/__tests__/isContentAgentConfigured.test.ts create mode 100644 lib/agents/content/__tests__/validateContentAgentEnv.test.ts diff --git a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts new file mode 100644 index 00000000..c334ae8d --- /dev/null +++ b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { handleContentAgentCallback } from "../handleContentAgentCallback"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateContentAgentCallback", () => ({ + validateContentAgentCallback: vi.fn(), +})); + +vi.mock("@/lib/agents/getThread", () => ({ + getThread: vi.fn(), +})); + +describe("handleContentAgentCallback", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.CODING_AGENT_CALLBACK_SECRET = "test-secret"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("returns 401 when x-callback-secret header is missing", async () => { + const request = new Request("http://localhost/api/content-agent/callback", { + method: "POST", + body: JSON.stringify({}), + }); + + const response = await handleContentAgentCallback(request); + expect(response.status).toBe(401); + }); + + it("returns 401 when secret does not match CODING_AGENT_CALLBACK_SECRET", async () => { + const request = new Request("http://localhost/api/content-agent/callback", { + method: "POST", + headers: { "x-callback-secret": "wrong-secret" }, + body: JSON.stringify({}), + }); + + const response = await handleContentAgentCallback(request); + expect(response.status).toBe(401); + }); + + it("returns 401 when CODING_AGENT_CALLBACK_SECRET env var is not set", async () => { + delete process.env.CODING_AGENT_CALLBACK_SECRET; + + const request = new Request("http://localhost/api/content-agent/callback", { + method: "POST", + headers: { "x-callback-secret": "test-secret" }, + body: JSON.stringify({}), + }); + + const response = await handleContentAgentCallback(request); + expect(response.status).toBe(401); + }); + + it("proceeds past auth when secret matches CODING_AGENT_CALLBACK_SECRET", async () => { + const request = new Request("http://localhost/api/content-agent/callback", { + method: "POST", + headers: { "x-callback-secret": "test-secret" }, + body: "not json", + }); + + const response = await handleContentAgentCallback(request); + // Should get past auth and fail on invalid JSON (400), not auth (401) + expect(response.status).toBe(400); + }); +}); diff --git a/lib/agents/content/__tests__/isContentAgentConfigured.test.ts b/lib/agents/content/__tests__/isContentAgentConfigured.test.ts new file mode 100644 index 00000000..d566159c --- /dev/null +++ b/lib/agents/content/__tests__/isContentAgentConfigured.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { isContentAgentConfigured } from "../isContentAgentConfigured"; +import { CONTENT_AGENT_REQUIRED_ENV_VARS } from "../validateContentAgentEnv"; + +describe("isContentAgentConfigured", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + for (const key of CONTENT_AGENT_REQUIRED_ENV_VARS) { + process.env[key] = "test-value"; + } + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("returns true when all required env vars are set", () => { + expect(isContentAgentConfigured()).toBe(true); + }); + + it("returns false when any required env var is missing", () => { + delete process.env.CODING_AGENT_CALLBACK_SECRET; + expect(isContentAgentConfigured()).toBe(false); + }); +}); diff --git a/lib/agents/content/__tests__/validateContentAgentEnv.test.ts b/lib/agents/content/__tests__/validateContentAgentEnv.test.ts new file mode 100644 index 00000000..5a958f51 --- /dev/null +++ b/lib/agents/content/__tests__/validateContentAgentEnv.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + validateContentAgentEnv, + CONTENT_AGENT_REQUIRED_ENV_VARS, +} from "../validateContentAgentEnv"; + +describe("validateContentAgentEnv", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + for (const key of CONTENT_AGENT_REQUIRED_ENV_VARS) { + process.env[key] = "test-value"; + } + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("does not throw when all required env vars are set", () => { + expect(() => validateContentAgentEnv()).not.toThrow(); + }); + + it("throws when a required env var is missing", () => { + delete process.env.SLACK_CONTENT_BOT_TOKEN; + expect(() => validateContentAgentEnv()).toThrow( + /Missing required environment variables/, + ); + }); + + it("lists all missing vars in the error message", () => { + delete process.env.SLACK_CONTENT_BOT_TOKEN; + delete process.env.REDIS_URL; + expect(() => validateContentAgentEnv()).toThrow("SLACK_CONTENT_BOT_TOKEN"); + expect(() => validateContentAgentEnv()).toThrow("REDIS_URL"); + }); + + it("requires CODING_AGENT_CALLBACK_SECRET, not CONTENT_AGENT_CALLBACK_SECRET", () => { + expect(CONTENT_AGENT_REQUIRED_ENV_VARS).toContain("CODING_AGENT_CALLBACK_SECRET"); + expect(CONTENT_AGENT_REQUIRED_ENV_VARS).not.toContain("CONTENT_AGENT_CALLBACK_SECRET"); + }); +}); From aa24a5d9347295d132407462ceab5d1987f587c4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 12:02:32 -0500 Subject: [PATCH 09/16] style: fix prettier formatting in test file Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/__tests__/validateContentAgentEnv.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/agents/content/__tests__/validateContentAgentEnv.test.ts b/lib/agents/content/__tests__/validateContentAgentEnv.test.ts index 5a958f51..4b514c1c 100644 --- a/lib/agents/content/__tests__/validateContentAgentEnv.test.ts +++ b/lib/agents/content/__tests__/validateContentAgentEnv.test.ts @@ -23,9 +23,7 @@ describe("validateContentAgentEnv", () => { it("throws when a required env var is missing", () => { delete process.env.SLACK_CONTENT_BOT_TOKEN; - expect(() => validateContentAgentEnv()).toThrow( - /Missing required environment variables/, - ); + expect(() => validateContentAgentEnv()).toThrow(/Missing required environment variables/); }); it("lists all missing vars in the error message", () => { From 552eeefcee1322b459221e39ba69e41750649947 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 12:08:56 -0500 Subject: [PATCH 10/16] debug: log missing env vars in isContentAgentConfigured Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/isContentAgentConfigured.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/agents/content/isContentAgentConfigured.ts b/lib/agents/content/isContentAgentConfigured.ts index b0fc2748..6da272f0 100644 --- a/lib/agents/content/isContentAgentConfigured.ts +++ b/lib/agents/content/isContentAgentConfigured.ts @@ -6,5 +6,9 @@ import { CONTENT_AGENT_REQUIRED_ENV_VARS } from "./validateContentAgentEnv"; * @returns Whether the content agent is fully configured */ export function isContentAgentConfigured(): boolean { - return CONTENT_AGENT_REQUIRED_ENV_VARS.every(name => !!process.env[name]); + const missing = CONTENT_AGENT_REQUIRED_ENV_VARS.filter(name => !process.env[name]); + if (missing.length > 0) { + console.warn(`[content-agent] Missing env vars: ${missing.join(", ")}`); + } + return missing.length === 0; } From 83f7e269935338d2d780349b88b50e17e2db4900 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 12:27:29 -0500 Subject: [PATCH 11/16] fix: strip Slack mention prefixes in parseMentionArgs Slack sends mention text as `<@U0ABC123> ...` but parseMentionArgs was treating the `<@...>` token as the artistAccountId, causing the real ID to be parsed as the template name. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/parseMentionArgs.test.ts | 51 +++++++++++++++++++ .../content/handlers/parseMentionArgs.ts | 3 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts diff --git a/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts b/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts new file mode 100644 index 00000000..aed3ed0e --- /dev/null +++ b/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { parseMentionArgs } from "../parseMentionArgs"; +import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; + +describe("parseMentionArgs", () => { + const UUID = "1873859c-dd37-4e9a-9bac-80d3558527a9"; + + it("parses a plain artist account ID", () => { + const result = parseMentionArgs(UUID); + expect(result.artistAccountId).toBe(UUID); + expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); + }); + + it("strips Slack user mention prefix from text", () => { + const result = parseMentionArgs(`<@U0ABC123> ${UUID}`); + expect(result.artistAccountId).toBe(UUID); + expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); + }); + + it("strips multiple Slack mentions before the artist ID", () => { + const result = parseMentionArgs(`<@U0ABC123> <@U9999999> ${UUID}`); + expect(result.artistAccountId).toBe(UUID); + expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); + }); + + it("parses template after artist ID with mention prefix", () => { + const result = parseMentionArgs(`<@U0ABC123> ${UUID} my-template`); + expect(result.artistAccountId).toBe(UUID); + expect(result.template).toBe("my-template"); + }); + + it("parses batch after artist ID with mention prefix", () => { + const result = parseMentionArgs(`<@U0ABC123> ${UUID} batch=5`); + expect(result.artistAccountId).toBe(UUID); + expect(result.batch).toBe(5); + }); + + it("parses lipsync flag with mention prefix", () => { + const result = parseMentionArgs(`<@U0ABC123> ${UUID} lipsync`); + expect(result.artistAccountId).toBe(UUID); + expect(result.lipsync).toBe(true); + }); + + it("parses all options together with mention prefix", () => { + const result = parseMentionArgs(`<@U0ABC123> ${UUID} my-template batch=3 lipsync`); + expect(result.artistAccountId).toBe(UUID); + expect(result.template).toBe("my-template"); + expect(result.batch).toBe(3); + expect(result.lipsync).toBe(true); + }); +}); diff --git a/lib/agents/content/handlers/parseMentionArgs.ts b/lib/agents/content/handlers/parseMentionArgs.ts index 66bbce44..ebdf1a04 100644 --- a/lib/agents/content/handlers/parseMentionArgs.ts +++ b/lib/agents/content/handlers/parseMentionArgs.ts @@ -9,7 +9,8 @@ import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; * @returns Parsed content generation parameters */ export function parseMentionArgs(text: string) { - const tokens = text.trim().split(/\s+/); + const stripped = text.replace(/<@[A-Z0-9]+>/g, "").trim(); + const tokens = stripped.split(/\s+/); const artistAccountId = tokens[0]; let template = DEFAULT_CONTENT_TEMPLATE; let batch = 1; From 4f4c2d48e928943887b2fe8642c212f6fc8c804a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 14:02:57 -0500 Subject: [PATCH 12/16] fix: handle mixed-case Slack mention IDs and add debug logging The regex only matched uppercase <@U0ABC123> but Slack IDs can contain lowercase letters. Also logs raw mention text to diagnose parsing issues. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../handlers/__tests__/parseMentionArgs.test.ts | 11 +++++++++++ lib/agents/content/handlers/parseMentionArgs.ts | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts b/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts index aed3ed0e..27bfb19a 100644 --- a/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts +++ b/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts @@ -48,4 +48,15 @@ describe("parseMentionArgs", () => { expect(result.batch).toBe(3); expect(result.lipsync).toBe(true); }); + + it("strips mentions with mixed-case IDs", () => { + const result = parseMentionArgs(`<@U06d5FLHYQZ> ${UUID}`); + expect(result.artistAccountId).toBe(UUID); + expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); + }); + + it("strips mentions with lowercase IDs", () => { + const result = parseMentionArgs(`<@u0abc123> ${UUID}`); + expect(result.artistAccountId).toBe(UUID); + }); }); diff --git a/lib/agents/content/handlers/parseMentionArgs.ts b/lib/agents/content/handlers/parseMentionArgs.ts index ebdf1a04..bcdae614 100644 --- a/lib/agents/content/handlers/parseMentionArgs.ts +++ b/lib/agents/content/handlers/parseMentionArgs.ts @@ -9,7 +9,8 @@ import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; * @returns Parsed content generation parameters */ export function parseMentionArgs(text: string) { - const stripped = text.replace(/<@[A-Z0-9]+>/g, "").trim(); + console.log("[content-agent] parseMentionArgs raw text:", JSON.stringify(text)); + const stripped = text.replace(/<@[A-Za-z0-9]+>/g, "").trim(); const tokens = stripped.split(/\s+/); const artistAccountId = tokens[0]; let template = DEFAULT_CONTENT_TEMPLATE; From 132142299934293be8d2780c61874e4f74fd3c2d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 14:04:08 -0500 Subject: [PATCH 13/16] debug: hardcode artist ID for testing content agent Temporarily hardcodes artist ID 1873859c-dd37-4e9a-9bac-80d3558527a9 to bypass mention parsing issues during testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/handlers/registerOnNewMention.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 0187a9f0..5d85f691 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -17,7 +17,10 @@ import { parseMentionArgs } from "./parseMentionArgs"; export function registerOnNewMention(bot: ContentAgentBot) { bot.onNewMention(async (thread, message) => { try { - const { artistAccountId, template, batch, lipsync } = parseMentionArgs(message.text); + const HARDCODED_ARTIST_ID = "1873859c-dd37-4e9a-9bac-80d3558527a9"; + const parsed = parseMentionArgs(message.text); + const artistAccountId = HARDCODED_ARTIST_ID; + const { template, batch, lipsync } = parsed; if (!artistAccountId) { await thread.post( From 48e9b4eb7b311b036565b04e01d2229844f8a019 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 14:05:08 -0500 Subject: [PATCH 14/16] refactor: remove parseMentionArgs, hardcode defaults for testing Simplifies onNewMention to use hardcoded artist ID and default values for template, batch, and lipsync to get end-to-end flow working. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/parseMentionArgs.test.ts | 62 ------------------- .../content/handlers/parseMentionArgs.ts | 33 ---------- .../content/handlers/registerOnNewMention.ts | 23 ++----- 3 files changed, 5 insertions(+), 113 deletions(-) delete mode 100644 lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts delete mode 100644 lib/agents/content/handlers/parseMentionArgs.ts diff --git a/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts b/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts deleted file mode 100644 index 27bfb19a..00000000 --- a/lib/agents/content/handlers/__tests__/parseMentionArgs.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseMentionArgs } from "../parseMentionArgs"; -import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; - -describe("parseMentionArgs", () => { - const UUID = "1873859c-dd37-4e9a-9bac-80d3558527a9"; - - it("parses a plain artist account ID", () => { - const result = parseMentionArgs(UUID); - expect(result.artistAccountId).toBe(UUID); - expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); - }); - - it("strips Slack user mention prefix from text", () => { - const result = parseMentionArgs(`<@U0ABC123> ${UUID}`); - expect(result.artistAccountId).toBe(UUID); - expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); - }); - - it("strips multiple Slack mentions before the artist ID", () => { - const result = parseMentionArgs(`<@U0ABC123> <@U9999999> ${UUID}`); - expect(result.artistAccountId).toBe(UUID); - expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); - }); - - it("parses template after artist ID with mention prefix", () => { - const result = parseMentionArgs(`<@U0ABC123> ${UUID} my-template`); - expect(result.artistAccountId).toBe(UUID); - expect(result.template).toBe("my-template"); - }); - - it("parses batch after artist ID with mention prefix", () => { - const result = parseMentionArgs(`<@U0ABC123> ${UUID} batch=5`); - expect(result.artistAccountId).toBe(UUID); - expect(result.batch).toBe(5); - }); - - it("parses lipsync flag with mention prefix", () => { - const result = parseMentionArgs(`<@U0ABC123> ${UUID} lipsync`); - expect(result.artistAccountId).toBe(UUID); - expect(result.lipsync).toBe(true); - }); - - it("parses all options together with mention prefix", () => { - const result = parseMentionArgs(`<@U0ABC123> ${UUID} my-template batch=3 lipsync`); - expect(result.artistAccountId).toBe(UUID); - expect(result.template).toBe("my-template"); - expect(result.batch).toBe(3); - expect(result.lipsync).toBe(true); - }); - - it("strips mentions with mixed-case IDs", () => { - const result = parseMentionArgs(`<@U06d5FLHYQZ> ${UUID}`); - expect(result.artistAccountId).toBe(UUID); - expect(result.template).toBe(DEFAULT_CONTENT_TEMPLATE); - }); - - it("strips mentions with lowercase IDs", () => { - const result = parseMentionArgs(`<@u0abc123> ${UUID}`); - expect(result.artistAccountId).toBe(UUID); - }); -}); diff --git a/lib/agents/content/handlers/parseMentionArgs.ts b/lib/agents/content/handlers/parseMentionArgs.ts deleted file mode 100644 index bcdae614..00000000 --- a/lib/agents/content/handlers/parseMentionArgs.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; - -/** - * Parses the mention text into content generation parameters. - * - * Format: [template] [batch=N] [lipsync] - * - * @param text - The raw mention text to parse - * @returns Parsed content generation parameters - */ -export function parseMentionArgs(text: string) { - console.log("[content-agent] parseMentionArgs raw text:", JSON.stringify(text)); - const stripped = text.replace(/<@[A-Za-z0-9]+>/g, "").trim(); - const tokens = stripped.split(/\s+/); - const artistAccountId = tokens[0]; - let template = DEFAULT_CONTENT_TEMPLATE; - let batch = 1; - let lipsync = false; - - for (let i = 1; i < tokens.length; i++) { - const token = tokens[i].toLowerCase(); - if (token.startsWith("batch=")) { - const n = parseInt(token.split("=")[1], 10); - if (!isNaN(n) && n >= 1 && n <= 30) batch = n; - } else if (token === "lipsync") { - lipsync = true; - } else if (!token.startsWith("batch") && token !== "lipsync") { - template = tokens[i]; // preserve original case for template name - } - } - - return { artistAccountId, template, batch, lipsync }; -} diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 5d85f691..853f28bf 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -4,8 +4,7 @@ import { triggerPollContentRun } from "@/lib/trigger/triggerPollContentRun"; import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; -import { isSupportedContentTemplate } from "@/lib/content/contentTemplates"; -import { parseMentionArgs } from "./parseMentionArgs"; +import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; /** * Registers the onNewMention handler on the content agent bot. @@ -17,22 +16,10 @@ import { parseMentionArgs } from "./parseMentionArgs"; export function registerOnNewMention(bot: ContentAgentBot) { bot.onNewMention(async (thread, message) => { try { - const HARDCODED_ARTIST_ID = "1873859c-dd37-4e9a-9bac-80d3558527a9"; - const parsed = parseMentionArgs(message.text); - const artistAccountId = HARDCODED_ARTIST_ID; - const { template, batch, lipsync } = parsed; - - if (!artistAccountId) { - await thread.post( - "Please provide an artist account ID.\n\nUsage: `@RecoupContentAgent [template] [batch=N] [lipsync]`", - ); - return; - } - - if (!isSupportedContentTemplate(template)) { - await thread.post(`Unsupported template: \`${template}\`. Check available templates.`); - return; - } + const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9"; + const template = DEFAULT_CONTENT_TEMPLATE; + const batch = 1; + const lipsync = false; // Resolve artist slug const artistSlug = await resolveArtistSlug(artistAccountId); From 337c8db84f96f6edc2697307c25311c0c6862978 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Mar 2026 14:08:36 -0500 Subject: [PATCH 15/16] fix: use correct accountId for content agent testing accountId (fb678396-...) is the user's account, artistAccountId (1873859c-...) is the artist. Previously both were set to the artist ID. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/handlers/registerOnNewMention.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 853f28bf..28469079 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -16,6 +16,7 @@ import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; export function registerOnNewMention(bot: ContentAgentBot) { bot.onNewMention(async (thread, message) => { try { + const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9"; const template = DEFAULT_CONTENT_TEMPLATE; const batch = 1; @@ -34,7 +35,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { let githubRepo: string; try { const readiness = await getArtistContentReadiness({ - accountId: artistAccountId, + accountId, artistAccountId, artistSlug, }); @@ -60,7 +61,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { // Trigger content creation const payload = { - accountId: artistAccountId, + accountId, artistSlug, template, lipsync, From 694f2013b08b8f3d964829b3485da30beb0cd550 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Wed, 25 Mar 2026 20:53:17 +0000 Subject: [PATCH 16/16] fix: remove coding-agent getThread wrapper and fix lint issues - Delete lib/coding-agent/getThread.ts wrapper (KISS nit from code review) - Update callers to import getThread directly from lib/agents/getThread - Fix unused 'message' parameter in registerOnNewMention.ts - Update tests to use shared getThread path Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- lib/agents/content/handlers/registerOnNewMention.ts | 2 +- lib/coding-agent/__tests__/getThread.test.ts | 4 ++-- lib/coding-agent/__tests__/handlePRCreated.test.ts | 2 +- lib/coding-agent/getThread.ts | 11 ----------- lib/coding-agent/handleCodingAgentCallback.ts | 4 ++-- lib/coding-agent/handlePRCreated.ts | 5 +++-- 6 files changed, 9 insertions(+), 19 deletions(-) delete mode 100644 lib/coding-agent/getThread.ts diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 28469079..592412f9 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -14,7 +14,7 @@ import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; * @param bot - The content agent bot instance to register the handler on */ export function registerOnNewMention(bot: ContentAgentBot) { - bot.onNewMention(async (thread, message) => { + bot.onNewMention(async (thread, _) => { try { const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9"; diff --git a/lib/coding-agent/__tests__/getThread.test.ts b/lib/coding-agent/__tests__/getThread.test.ts index e44198aa..ce876a11 100644 --- a/lib/coding-agent/__tests__/getThread.test.ts +++ b/lib/coding-agent/__tests__/getThread.test.ts @@ -4,9 +4,9 @@ vi.mock("chat", () => ({ ThreadImpl: vi.fn().mockImplementation((config: Record) => config), })); -describe("getThread", () => { +describe("getThread (shared)", () => { it("parses adapter name and channel ID from thread ID", async () => { - const { getThread } = await import("../getThread"); + const { getThread } = await import("@/lib/agents/getThread"); const { ThreadImpl } = await import("chat"); getThread("slack:C123:1234567890.123456"); diff --git a/lib/coding-agent/__tests__/handlePRCreated.test.ts b/lib/coding-agent/__tests__/handlePRCreated.test.ts index 3ccac045..928e9e15 100644 --- a/lib/coding-agent/__tests__/handlePRCreated.test.ts +++ b/lib/coding-agent/__tests__/handlePRCreated.test.ts @@ -5,7 +5,7 @@ const mockThread = { setState: vi.fn(), }; -vi.mock("../getThread", () => ({ +vi.mock("@/lib/agents/getThread", () => ({ getThread: vi.fn(() => mockThread), })); diff --git a/lib/coding-agent/getThread.ts b/lib/coding-agent/getThread.ts deleted file mode 100644 index 5cf37231..00000000 --- a/lib/coding-agent/getThread.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getThread as getAgentThread } from "@/lib/agents/getThread"; -import type { CodingAgentThreadState } from "./types"; - -/** - * Reconstructs a Thread from a stored thread ID using the Chat SDK singleton. - * - * @param threadId - */ -export function getThread(threadId: string) { - return getAgentThread(threadId); -} diff --git a/lib/coding-agent/handleCodingAgentCallback.ts b/lib/coding-agent/handleCodingAgentCallback.ts index 53ff1424..e4cabda1 100644 --- a/lib/coding-agent/handleCodingAgentCallback.ts +++ b/lib/coding-agent/handleCodingAgentCallback.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCodingAgentCallback } from "./validateCodingAgentCallback"; -import { getThread } from "./getThread"; +import { getThread } from "@/lib/agents/getThread"; import { handlePRCreated } from "./handlePRCreated"; import { buildPRCard } from "./buildPRCard"; import { setCodingAgentPRState } from "./prState"; @@ -41,7 +41,7 @@ export async function handleCodingAgentCallback(request: Request): Promise(validated.threadId); // Post agent stdout to the thread so users see the full agent response if (validated.stdout?.trim()) { diff --git a/lib/coding-agent/handlePRCreated.ts b/lib/coding-agent/handlePRCreated.ts index 77837b28..52bee268 100644 --- a/lib/coding-agent/handlePRCreated.ts +++ b/lib/coding-agent/handlePRCreated.ts @@ -1,7 +1,8 @@ -import { getThread } from "./getThread"; +import { getThread } from "@/lib/agents/getThread"; import { buildPRCard } from "./buildPRCard"; import { setCodingAgentPRState } from "./prState"; import type { CodingAgentCallbackBody } from "./validateCodingAgentCallback"; +import type { CodingAgentThreadState } from "./types"; /** * Handles the pr_created callback status. @@ -11,7 +12,7 @@ import type { CodingAgentCallbackBody } from "./validateCodingAgentCallback"; * @param body */ export async function handlePRCreated(threadId: string, body: CodingAgentCallbackBody) { - const thread = getThread(threadId); + const thread = getThread(threadId); const prs = body.prs ?? []; const card = buildPRCard("PRs Created", prs);