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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 7 additions & 53 deletions app/api/coding-agent/[platform]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
14 changes: 14 additions & 0 deletions app/api/content-agent/[platform]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createPlatformRoutes } from "@/lib/agents/createPlatformRoutes";
import { contentAgentBot } from "@/lib/agents/content/bot";
import "@/lib/agents/content/handlers/registerHandlers";

/**
* GET & POST /api/content-agent/[platform]
*
* Webhook endpoints for the content agent bot.
* Handles Slack webhooks via dynamic [platform] segment.
*/
export const { GET, POST } = createPlatformRoutes({
getBot: () => contentAgentBot!,
isConfigured: () => contentAgentBot !== null,
});
21 changes: 21 additions & 0 deletions app/api/content-agent/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { NextRequest } from "next/server";
import { contentAgentBot } from "@/lib/agents/content/bot";
import { handleContentAgentCallback } from "@/lib/agents/content/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) {
if (!contentAgentBot) {
return Response.json({ error: "Content agent not configured" }, { status: 503 });
}

await contentAgentBot.initialize();
return handleContentAgentCallback(request);
}
73 changes: 73 additions & 0 deletions lib/agents/content/__tests__/handleContentAgentCallback.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions lib/agents/content/__tests__/isContentAgentConfigured.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions lib/agents/content/__tests__/validateContentAgentEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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");
});
});
44 changes: 44 additions & 0 deletions lib/agents/content/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Chat } from "chat";
import { SlackAdapter } from "@chat-adapter/slack";
import { agentLogger, createAgentState } from "@/lib/agents/createAgentState";
import type { ContentAgentThreadState } from "./types";
import { isContentAgentConfigured } from "./isContentAgentConfigured";
import { validateContentAgentEnv } from "./validateContentAgentEnv";

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
*/
function createContentAgentBot() {
validateContentAgentEnv();

const state = createAgentState("content-agent");

const slack = new SlackAdapter({
botToken: process.env.SLACK_CONTENT_BOT_TOKEN!,
signingSecret: process.env.SLACK_CONTENT_SIGNING_SECRET!,
logger: agentLogger,
});

return new Chat<ContentAgentAdapters, ContentAgentThreadState>({
userName: "Recoup Content Agent",
adapters: { slack },
state,
});
}

export type ContentAgentBot = ReturnType<typeof createContentAgentBot>;

/**
* 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 const contentAgentBot: ContentAgentBot | null = isContentAgentConfigured()
? createContentAgentBot().registerSingleton()
: null;
98 changes: 98 additions & 0 deletions lib/agents/content/handleContentAgentCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { timingSafeEqual } from "crypto";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateContentAgentCallback } from "./validateContentAgentCallback";
import { getThread } from "@/lib/agents/getThread";
import type { ContentAgentThreadState } from "./types";

/**
* Handles content agent task callback from Trigger.dev.
* Verifies the shared secret and dispatches based on callback status.
*
* @param request - The incoming callback request
* @returns A NextResponse
*/
export async function handleContentAgentCallback(request: Request): Promise<NextResponse> {
const secret = request.headers.get("x-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);

if (
!secret ||
!expectedSecret ||
secretBuf.length !== expectedBuf.length ||
!timingSafeEqual(secretBuf, expectedBuf)
) {
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<ContentAgentThreadState>(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 ?? [];
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() });
}
Loading
Loading