Skip to content

Commit 237567d

Browse files
recoup-coding-agentCTO AgentPaperclip-Paperclipsweetmantechclaude
authored
feat: embed videos inline in Slack responses (#389)
* feat: embed videos inline in Slack instead of posting URLs When the content agent finishes generating a video, download it and post it as a file upload so it appears inline in the Slack thread. Falls back to the old URL-link behavior if the download fails. - New downloadVideoBuffer utility to fetch video data - Updated handleContentAgentCallback to use thread.post({ files }) - 14 new tests (4 downloadVideoBuffer + 10 callback handler) Co-Authored-By: Paperclip <noreply@paperclip.ing> * refactor: extract getFilenameFromUrl and postVideoResults, parallelize downloads - Extract getFilenameFromUrl into its own file with tests for edge cases (encoded chars, no extension, fal.ai URLs, query params) - Extract video embed loop into postVideoResults (SRP/OCP) - Download all videos in parallel with Promise.all instead of sequentially, then post to Slack in order - Update handleContentAgentCallback tests to mock postVideoResults Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: formatting and Thread type compatibility Run prettier on new files. Change Thread.post return type from Promise<void> to Promise<unknown> to match ThreadImpl signature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CTO Agent <cto@recoup.ai> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6f9494b commit 237567d

File tree

8 files changed

+420
-11
lines changed

8 files changed

+420
-11
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { downloadVideoBuffer } from "../downloadVideoBuffer";
3+
4+
describe("downloadVideoBuffer", () => {
5+
beforeEach(() => {
6+
vi.clearAllMocks();
7+
vi.restoreAllMocks();
8+
});
9+
10+
it("returns a Buffer with the video data on success", async () => {
11+
const fakeData = new Uint8Array([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
12+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
13+
new Response(fakeData, {
14+
status: 200,
15+
headers: { "content-type": "video/mp4" },
16+
}),
17+
);
18+
19+
const result = await downloadVideoBuffer("https://example.com/video.mp4");
20+
expect(result).toBeInstanceOf(Buffer);
21+
expect(result!.length).toBe(fakeData.length);
22+
});
23+
24+
it("returns null when fetch returns a non-ok status", async () => {
25+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));
26+
27+
const result = await downloadVideoBuffer("https://example.com/missing.mp4");
28+
expect(result).toBeNull();
29+
});
30+
31+
it("returns null when fetch throws a network error", async () => {
32+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
33+
34+
const result = await downloadVideoBuffer("https://example.com/video.mp4");
35+
expect(result).toBeNull();
36+
});
37+
38+
it("extracts the filename from the URL path", async () => {
39+
const fakeData = new Uint8Array([0x01, 0x02]);
40+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeData, { status: 200 }));
41+
42+
const result = await downloadVideoBuffer(
43+
"https://cdn.example.com/path/to/my-video.mp4?token=abc",
44+
);
45+
expect(result).toBeInstanceOf(Buffer);
46+
});
47+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from "vitest";
2+
import { getFilenameFromUrl } from "../getFilenameFromUrl";
3+
4+
describe("getFilenameFromUrl", () => {
5+
it("extracts filename from a simple URL", () => {
6+
expect(getFilenameFromUrl("https://cdn.example.com/path/to/video.mp4")).toBe("video.mp4");
7+
});
8+
9+
it("extracts filename from URL with query params", () => {
10+
expect(getFilenameFromUrl("https://cdn.example.com/video.mp4?token=abc&t=123")).toBe(
11+
"video.mp4",
12+
);
13+
});
14+
15+
it("handles URL-encoded characters", () => {
16+
expect(getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4")).toBe(
17+
"my%20video%20file.mp4",
18+
);
19+
});
20+
21+
it("falls back to video.mp4 when URL has no extension", () => {
22+
expect(getFilenameFromUrl("https://cdn.example.com/path/to/video")).toBe("video.mp4");
23+
});
24+
25+
it("falls back to video.mp4 when URL path ends with slash", () => {
26+
expect(getFilenameFromUrl("https://cdn.example.com/path/")).toBe("video.mp4");
27+
});
28+
29+
it("falls back to video.mp4 for invalid URLs", () => {
30+
expect(getFilenameFromUrl("not-a-url")).toBe("video.mp4");
31+
});
32+
33+
it("handles fal.ai storage URLs", () => {
34+
expect(
35+
getFilenameFromUrl(
36+
"https://v3b.fal.media/files/b/0a9486c8/sjfeqG-MFh_3aG213aIU2_final-video.mp4",
37+
),
38+
).toBe("sjfeqG-MFh_3aG213aIU2_final-video.mp4");
39+
});
40+
});

lib/agents/content/__tests__/handleContentAgentCallback.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ vi.mock("@/lib/agents/getThread", () => ({
1313
getThread: vi.fn(),
1414
}));
1515

16+
vi.mock("../postVideoResults", () => ({
17+
postVideoResults: vi.fn().mockResolvedValue(undefined),
18+
}));
19+
20+
const { validateContentAgentCallback } = await import("../validateContentAgentCallback");
21+
const { getThread } = await import("@/lib/agents/getThread");
22+
const { postVideoResults } = await import("../postVideoResults");
23+
24+
const mockedValidate = vi.mocked(validateContentAgentCallback);
25+
const mockedGetThread = vi.mocked(getThread);
26+
const mockedPostVideos = vi.mocked(postVideoResults);
27+
1628
describe("handleContentAgentCallback", () => {
1729
const originalEnv = { ...process.env };
1830

@@ -70,4 +82,102 @@ describe("handleContentAgentCallback", () => {
7082
// Should get past auth and fail on invalid JSON (400), not auth (401)
7183
expect(response.status).toBe(400);
7284
});
85+
86+
describe("completed callback with videos", () => {
87+
function makeAuthRequest(body: object) {
88+
return new Request("http://localhost/api/content-agent/callback", {
89+
method: "POST",
90+
headers: { "x-callback-secret": "test-secret" },
91+
body: JSON.stringify(body),
92+
});
93+
}
94+
95+
function mockThread() {
96+
const thread = {
97+
post: vi.fn().mockResolvedValue(undefined),
98+
state: Promise.resolve({ status: "running" }),
99+
setState: vi.fn().mockResolvedValue(undefined),
100+
};
101+
mockedGetThread.mockReturnValue(thread as never);
102+
return thread;
103+
}
104+
105+
it("calls postVideoResults with videos and failed count", async () => {
106+
const thread = mockThread();
107+
mockedValidate.mockReturnValue({
108+
threadId: "slack:C123:T456",
109+
status: "completed",
110+
results: [
111+
{
112+
runId: "run-1",
113+
status: "completed",
114+
videoUrl: "https://cdn.example.com/video.mp4",
115+
captionText: "Test",
116+
},
117+
{ runId: "run-2", status: "failed", error: "render error" },
118+
],
119+
});
120+
121+
const response = await handleContentAgentCallback(makeAuthRequest({}));
122+
123+
expect(response.status).toBe(200);
124+
expect(mockedPostVideos).toHaveBeenCalledWith(
125+
thread,
126+
[expect.objectContaining({ videoUrl: "https://cdn.example.com/video.mp4" })],
127+
1,
128+
);
129+
});
130+
131+
it("posts fallback message when no videos produced", async () => {
132+
const thread = mockThread();
133+
mockedValidate.mockReturnValue({
134+
threadId: "slack:C123:T456",
135+
status: "completed",
136+
results: [{ runId: "run-1", status: "failed", error: "render error" }],
137+
});
138+
139+
const response = await handleContentAgentCallback(makeAuthRequest({}));
140+
141+
expect(response.status).toBe(200);
142+
expect(thread.post).toHaveBeenCalledWith(
143+
"Content generation finished but no videos were produced.",
144+
);
145+
expect(mockedPostVideos).not.toHaveBeenCalled();
146+
});
147+
148+
it("skips duplicate delivery when thread status is not running", async () => {
149+
const thread = {
150+
post: vi.fn(),
151+
state: Promise.resolve({ status: "completed" }),
152+
setState: vi.fn(),
153+
};
154+
mockedGetThread.mockReturnValue(thread as never);
155+
mockedValidate.mockReturnValue({
156+
threadId: "slack:C123:T456",
157+
status: "completed",
158+
results: [],
159+
});
160+
161+
const response = await handleContentAgentCallback(makeAuthRequest({}));
162+
const body = await response.json();
163+
164+
expect(body.skipped).toBe(true);
165+
expect(thread.post).not.toHaveBeenCalled();
166+
});
167+
168+
it("sets thread state to completed after posting", async () => {
169+
const thread = mockThread();
170+
mockedValidate.mockReturnValue({
171+
threadId: "slack:C123:T456",
172+
status: "completed",
173+
results: [
174+
{ runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/v.mp4" },
175+
],
176+
});
177+
178+
await handleContentAgentCallback(makeAuthRequest({}));
179+
180+
expect(thread.setState).toHaveBeenCalledWith({ status: "completed" });
181+
});
182+
});
73183
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { postVideoResults } from "../postVideoResults";
3+
4+
vi.mock("../downloadVideoBuffer", () => ({
5+
downloadVideoBuffer: vi.fn(),
6+
}));
7+
8+
const { downloadVideoBuffer } = await import("../downloadVideoBuffer");
9+
const mockedDownload = vi.mocked(downloadVideoBuffer);
10+
11+
describe("postVideoResults", () => {
12+
let thread: { post: ReturnType<typeof vi.fn> };
13+
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
thread = { post: vi.fn().mockResolvedValue(undefined) };
17+
});
18+
19+
it("downloads videos in parallel and posts each as a file upload", async () => {
20+
const buf1 = Buffer.from([0x01]);
21+
const buf2 = Buffer.from([0x02]);
22+
mockedDownload.mockResolvedValueOnce(buf1).mockResolvedValueOnce(buf2);
23+
24+
const videos = [
25+
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" },
26+
{ runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" },
27+
];
28+
29+
await postVideoResults(thread as never, videos, 0);
30+
31+
expect(mockedDownload).toHaveBeenCalledTimes(2);
32+
expect(thread.post).toHaveBeenCalledTimes(2);
33+
expect(thread.post).toHaveBeenCalledWith(
34+
expect.objectContaining({
35+
files: [expect.objectContaining({ filename: "v1.mp4" })],
36+
}),
37+
);
38+
});
39+
40+
it("falls back to URL link when download fails", async () => {
41+
mockedDownload.mockResolvedValue(null);
42+
43+
const videos = [
44+
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" },
45+
];
46+
47+
await postVideoResults(thread as never, videos, 0);
48+
49+
expect(thread.post).toHaveBeenCalledWith(
50+
expect.stringContaining("https://cdn.example.com/v.mp4"),
51+
);
52+
});
53+
54+
it("includes caption in markdown when present", async () => {
55+
mockedDownload.mockResolvedValue(Buffer.from([0x01]));
56+
57+
const videos = [
58+
{
59+
runId: "r1",
60+
status: "completed" as const,
61+
videoUrl: "https://cdn.example.com/v.mp4",
62+
captionText: "great song",
63+
},
64+
];
65+
66+
await postVideoResults(thread as never, videos, 0);
67+
68+
expect(thread.post).toHaveBeenCalledWith(
69+
expect.objectContaining({
70+
markdown: expect.stringContaining("great song"),
71+
}),
72+
);
73+
});
74+
75+
it("posts failed run count when failedCount > 0", async () => {
76+
mockedDownload.mockResolvedValue(Buffer.from([0x01]));
77+
78+
const videos = [
79+
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" },
80+
];
81+
82+
await postVideoResults(thread as never, videos, 2);
83+
84+
const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0];
85+
expect(lastCall).toContain("2");
86+
expect(lastCall).toContain("failed");
87+
});
88+
89+
it("does not post failed message when failedCount is 0", async () => {
90+
mockedDownload.mockResolvedValue(Buffer.from([0x01]));
91+
92+
const videos = [
93+
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" },
94+
];
95+
96+
await postVideoResults(thread as never, videos, 0);
97+
98+
expect(thread.post).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it("labels videos when there are multiple", async () => {
102+
mockedDownload.mockResolvedValue(Buffer.from([0x01]));
103+
104+
const videos = [
105+
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" },
106+
{ runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" },
107+
];
108+
109+
await postVideoResults(thread as never, videos, 0);
110+
111+
expect(thread.post).toHaveBeenCalledWith(
112+
expect.objectContaining({
113+
markdown: expect.stringContaining("Video 1"),
114+
}),
115+
);
116+
});
117+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Downloads a video from a URL and returns the data as a Buffer.
3+
*
4+
* @param url - The video URL to download
5+
* @returns The video data as a Buffer, or null if the download fails
6+
*/
7+
export async function downloadVideoBuffer(url: string): Promise<Buffer | null> {
8+
try {
9+
const response = await fetch(url);
10+
11+
if (!response.ok) {
12+
console.error(`Failed to download video: HTTP ${response.status} from ${url}`);
13+
return null;
14+
}
15+
16+
const arrayBuffer = await response.arrayBuffer();
17+
return Buffer.from(arrayBuffer);
18+
} catch (error) {
19+
console.error("Failed to download video:", error);
20+
return null;
21+
}
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Extracts the filename from a URL path, falling back to "video.mp4".
3+
*
4+
* @param url - The video URL
5+
* @returns The extracted filename
6+
*/
7+
export function getFilenameFromUrl(url: string): string {
8+
try {
9+
const pathname = new URL(url).pathname;
10+
const segments = pathname.split("/");
11+
const last = segments[segments.length - 1];
12+
return last && last.includes(".") ? last : "video.mp4";
13+
} catch {
14+
return "video.mp4";
15+
}
16+
}

lib/agents/content/handleContentAgentCallback.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
33
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
44
import { validateContentAgentCallback } from "./validateContentAgentCallback";
55
import { getThread } from "@/lib/agents/getThread";
6+
import { postVideoResults } from "./postVideoResults";
67
import type { ContentAgentThreadState } from "./types";
78

89
/**
@@ -62,17 +63,7 @@ export async function handleContentAgentCallback(request: Request): Promise<Next
6263
const failed = results.filter(r => r.status === "failed");
6364

6465
if (videos.length > 0) {
65-
const lines = videos.map((v, i) => {
66-
const label = videos.length > 1 ? `**Video ${i + 1}:** ` : "";
67-
const caption = v.captionText ? `\n> ${v.captionText}` : "";
68-
return `${label}${v.videoUrl}${caption}`;
69-
});
70-
71-
if (failed.length > 0) {
72-
lines.push(`\n_${failed.length} run(s) failed._`);
73-
}
74-
75-
await thread.post(lines.join("\n\n"));
66+
await postVideoResults(thread, videos, failed.length);
7667
} else {
7768
await thread.post("Content generation finished but no videos were produced.");
7869
}

0 commit comments

Comments
 (0)