-
Notifications
You must be signed in to change notification settings - Fork 6
feat: embed videos inline in Slack responses #389
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { downloadVideoBuffer } from "../downloadVideoBuffer"; | ||
|
|
||
| describe("downloadVideoBuffer", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("returns a Buffer with the video data on success", async () => { | ||
| const fakeData = new Uint8Array([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]); | ||
| vi.spyOn(globalThis, "fetch").mockResolvedValue( | ||
| new Response(fakeData, { | ||
| status: 200, | ||
| headers: { "content-type": "video/mp4" }, | ||
| }), | ||
| ); | ||
|
|
||
| const result = await downloadVideoBuffer("https://example.com/video.mp4"); | ||
| expect(result).toBeInstanceOf(Buffer); | ||
| expect(result!.length).toBe(fakeData.length); | ||
| }); | ||
|
|
||
| it("returns null when fetch returns a non-ok status", async () => { | ||
| vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 })); | ||
|
|
||
| const result = await downloadVideoBuffer("https://example.com/missing.mp4"); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it("returns null when fetch throws a network error", async () => { | ||
| vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); | ||
|
|
||
| const result = await downloadVideoBuffer("https://example.com/video.mp4"); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it("extracts the filename from the URL path", async () => { | ||
| const fakeData = new Uint8Array([0x01, 0x02]); | ||
| vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeData, { status: 200 })); | ||
|
|
||
| const result = await downloadVideoBuffer( | ||
| "https://cdn.example.com/path/to/my-video.mp4?token=abc", | ||
| ); | ||
| expect(result).toBeInstanceOf(Buffer); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { getFilenameFromUrl } from "../getFilenameFromUrl"; | ||
|
|
||
| describe("getFilenameFromUrl", () => { | ||
| it("extracts filename from a simple URL", () => { | ||
| expect(getFilenameFromUrl("https://cdn.example.com/path/to/video.mp4")).toBe("video.mp4"); | ||
| }); | ||
|
|
||
| it("extracts filename from URL with query params", () => { | ||
| expect(getFilenameFromUrl("https://cdn.example.com/video.mp4?token=abc&t=123")).toBe( | ||
| "video.mp4", | ||
| ); | ||
| }); | ||
|
|
||
| it("handles URL-encoded characters", () => { | ||
| expect(getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4")).toBe( | ||
| "my%20video%20file.mp4", | ||
| ); | ||
| }); | ||
|
|
||
| it("falls back to video.mp4 when URL has no extension", () => { | ||
| expect(getFilenameFromUrl("https://cdn.example.com/path/to/video")).toBe("video.mp4"); | ||
| }); | ||
|
|
||
| it("falls back to video.mp4 when URL path ends with slash", () => { | ||
| expect(getFilenameFromUrl("https://cdn.example.com/path/")).toBe("video.mp4"); | ||
| }); | ||
|
|
||
| it("falls back to video.mp4 for invalid URLs", () => { | ||
| expect(getFilenameFromUrl("not-a-url")).toBe("video.mp4"); | ||
| }); | ||
|
|
||
| it("handles fal.ai storage URLs", () => { | ||
| expect( | ||
| getFilenameFromUrl( | ||
| "https://v3b.fal.media/files/b/0a9486c8/sjfeqG-MFh_3aG213aIU2_final-video.mp4", | ||
| ), | ||
| ).toBe("sjfeqG-MFh_3aG213aIU2_final-video.mp4"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { postVideoResults } from "../postVideoResults"; | ||
|
|
||
| vi.mock("../downloadVideoBuffer", () => ({ | ||
| downloadVideoBuffer: vi.fn(), | ||
| })); | ||
|
|
||
| const { downloadVideoBuffer } = await import("../downloadVideoBuffer"); | ||
| const mockedDownload = vi.mocked(downloadVideoBuffer); | ||
|
|
||
| describe("postVideoResults", () => { | ||
| let thread: { post: ReturnType<typeof vi.fn> }; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| thread = { post: vi.fn().mockResolvedValue(undefined) }; | ||
| }); | ||
|
|
||
| it("downloads videos in parallel and posts each as a file upload", async () => { | ||
| const buf1 = Buffer.from([0x01]); | ||
| const buf2 = Buffer.from([0x02]); | ||
| mockedDownload.mockResolvedValueOnce(buf1).mockResolvedValueOnce(buf2); | ||
|
|
||
| const videos = [ | ||
| { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" }, | ||
| { runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" }, | ||
| ]; | ||
|
|
||
| await postVideoResults(thread as never, videos, 0); | ||
|
|
||
| expect(mockedDownload).toHaveBeenCalledTimes(2); | ||
| expect(thread.post).toHaveBeenCalledTimes(2); | ||
| expect(thread.post).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| files: [expect.objectContaining({ filename: "v1.mp4" })], | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it("falls back to URL link when download fails", async () => { | ||
| mockedDownload.mockResolvedValue(null); | ||
|
|
||
| const videos = [ | ||
| { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, | ||
| ]; | ||
|
|
||
| await postVideoResults(thread as never, videos, 0); | ||
|
|
||
| expect(thread.post).toHaveBeenCalledWith( | ||
| expect.stringContaining("https://cdn.example.com/v.mp4"), | ||
| ); | ||
| }); | ||
|
|
||
| it("includes caption in markdown when present", async () => { | ||
| mockedDownload.mockResolvedValue(Buffer.from([0x01])); | ||
|
|
||
| const videos = [ | ||
| { | ||
| runId: "r1", | ||
| status: "completed" as const, | ||
| videoUrl: "https://cdn.example.com/v.mp4", | ||
| captionText: "great song", | ||
| }, | ||
| ]; | ||
|
|
||
| await postVideoResults(thread as never, videos, 0); | ||
|
|
||
| expect(thread.post).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| markdown: expect.stringContaining("great song"), | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it("posts failed run count when failedCount > 0", async () => { | ||
| mockedDownload.mockResolvedValue(Buffer.from([0x01])); | ||
|
|
||
| const videos = [ | ||
| { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, | ||
| ]; | ||
|
|
||
| await postVideoResults(thread as never, videos, 2); | ||
|
|
||
| const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0]; | ||
| expect(lastCall).toContain("2"); | ||
| expect(lastCall).toContain("failed"); | ||
| }); | ||
|
|
||
| it("does not post failed message when failedCount is 0", async () => { | ||
| mockedDownload.mockResolvedValue(Buffer.from([0x01])); | ||
|
|
||
| const videos = [ | ||
| { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, | ||
| ]; | ||
|
|
||
| await postVideoResults(thread as never, videos, 0); | ||
|
|
||
| expect(thread.post).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("labels videos when there are multiple", async () => { | ||
| mockedDownload.mockResolvedValue(Buffer.from([0x01])); | ||
|
|
||
| const videos = [ | ||
| { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" }, | ||
| { runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" }, | ||
| ]; | ||
|
|
||
| await postVideoResults(thread as never, videos, 0); | ||
|
|
||
| expect(thread.post).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| markdown: expect.stringContaining("Video 1"), | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /** | ||
| * Downloads a video from a URL and returns the data as a Buffer. | ||
| * | ||
| * @param url - The video URL to download | ||
| * @returns The video data as a Buffer, or null if the download fails | ||
| */ | ||
| export async function downloadVideoBuffer(url: string): Promise<Buffer | null> { | ||
| try { | ||
| const response = await fetch(url); | ||
|
|
||
| if (!response.ok) { | ||
| console.error(`Failed to download video: HTTP ${response.status} from ${url}`); | ||
| return null; | ||
| } | ||
|
|
||
| const arrayBuffer = await response.arrayBuffer(); | ||
| return Buffer.from(arrayBuffer); | ||
|
Comment on lines
+9
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's find and read the downloadVideoBuffer.ts file
fd downloadVideoBuffer.tsRepository: recoupable/api Length of output: 99 🏁 Script executed: # Also check for the file that uses it
fd handleContentAgentCallback.tsRepository: recoupable/api Length of output: 106 🏁 Script executed: # Get the file structure to understand the context better
git ls-files lib/agents/content/Repository: recoupable/api Length of output: 1105 🏁 Script executed: cat -n lib/agents/content/downloadVideoBuffer.tsRepository: recoupable/api Length of output: 854 🏁 Script executed: cat -n lib/agents/content/handleContentAgentCallback.tsRepository: recoupable/api Length of output: 5572 🏁 Script executed: # Check if there are any existing timeout or size guards elsewhere in the codebase
rg "timeout|maxSize|max-size|MAX_SIZE" lib/agents/content/ -A 2 -B 2Repository: recoupable/api Length of output: 1861 🏁 Script executed: # Check if there are any fetch configuration or timeout utilities elsewhere in the codebase
rg "AbortController|AbortSignal|timeout" lib/ -t ts -t tsx | head -20Repository: recoupable/api Length of output: 85 🏁 Script executed: # Check if there's a configured fetch wrapper or utility function
fd -e ts -e tsx lib/networking/ lib/utils/ | head -20Repository: recoupable/api Length of output: 155 🏁 Script executed: # Look for any environment variables or constants related to request timeouts
rg "TIMEOUT|timeout" lib/agents/content/ -t tsRepository: recoupable/api Length of output: 523 🏁 Script executed: # Look for AbortController or timeout patterns in the broader codebase
rg "AbortController|AbortSignal" lib/ -t ts | head -20Repository: recoupable/api Length of output: 219 🏁 Script executed: # Check if there's a networking utilities directory
fd -type d networkingRepository: recoupable/api Length of output: 228 🏁 Script executed: # Look for any fetch wrappers or similar patterns
rg "fetch\(" lib/ -t ts -B 2 -A 2 | head -40Repository: recoupable/api Length of output: 2324 🏁 Script executed: # Check the test file to understand expected behavior
cat -n lib/agents/content/__tests__/downloadVideoBuffer.test.tsRepository: recoupable/api Length of output: 2072 🏁 Script executed: # Check what URLs are being downloaded - look at the context that calls downloadVideoBuffer
rg "videoUrl" lib/agents/content/ -B 2 -A 2Repository: recoupable/api Length of output: 4883 🏁 Script executed: # Check if there are other similar download functions in the codebase and how they handle this
rg "arrayBuffer\(\)" lib/ -B 5 -A 2Repository: recoupable/api Length of output: 3235 🏁 Script executed: # Look at the examples of AbortController usage to see the pattern
cat -n lib/serpapi/searchGoogleImages.ts | grep -A 10 "AbortController"Repository: recoupable/api Length of output: 385 🏁 Script executed: # Check if there are any environment constants for max sizes
rg "MAX_.*SIZE|maxSize" lib/ -t tsRepository: recoupable/api Length of output: 288 Add timeout and max-size guards to this download. This runs on the callback request path and blocks the handler until complete. Additionally, redact the URL from the error log at line 12 to avoid exposing signed query parameters. 🤖 Prompt for AI Agents |
||
| } catch (error) { | ||
| console.error("Failed to download video:", error); | ||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| /** | ||
| * Extracts the filename from a URL path, falling back to "video.mp4". | ||
| * | ||
| * @param url - The video URL | ||
| * @returns The extracted filename | ||
| */ | ||
| export function getFilenameFromUrl(url: string): string { | ||
| try { | ||
| const pathname = new URL(url).pathname; | ||
| const segments = pathname.split("/"); | ||
| const last = segments[segments.length - 1]; | ||
| return last && last.includes(".") ? last : "video.mp4"; | ||
| } catch { | ||
| return "video.mp4"; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redact the asset URL in the error log.
Line 12 logs the full download URL. These asset URLs are commonly signed, so writing the query string to logs can leak temporary credentials. Log the status plus a redacted host/path instead.
🤖 Prompt for AI Agents