-
Notifications
You must be signed in to change notification settings - Fork 4
feat: Recoup Content Agent Slack bot #342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
2f57c7d
feat: Recoup Content Agent Slack bot and /api/launch endpoint
26db957
fix: lazy bot init and thread ID validation (review feedback)
9da3aef
fix: graceful 503 when content-agent env vars missing
a93b0f0
refactor(content-agent): address CLEAN code review feedback
2abed88
refactor(content-agent): address round 2 review feedback
efd3b1b
fix(content-agent): address round 3 CodeRabbit review feedback
5d7a59a
refactor: use CODING_AGENT_CALLBACK_SECRET instead of CONTENT_AGENT_C…
sweetmantech fe28e71
test: add tests for content agent env validation and callback auth
sweetmantech aa24a5d
style: fix prettier formatting in test file
sweetmantech 552eeef
debug: log missing env vars in isContentAgentConfigured
sweetmantech 83f7e26
fix: strip Slack mention prefixes in parseMentionArgs
sweetmantech 4f4c2d4
fix: handle mixed-case Slack mention IDs and add debug logging
sweetmantech 1321422
debug: hardcode artist ID for testing content agent
sweetmantech 48e9b4e
refactor: remove parseMentionArgs, hardcode defaults for testing
sweetmantech 337c8db
fix: use correct accountId for content agent testing
sweetmantech 694f201
fix: remove coding-agent getThread wrapper and fix lint issues
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
73
lib/agents/content/__tests__/handleContentAgentCallback.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
26
lib/agents/content/__tests__/isContentAgentConfigured.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
40
lib/agents/content/__tests__/validateContentAgentEnv.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| ) { | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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() }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.