From d0acd046db7fe18faad9449d7b828e25a78215ef Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 31 Mar 2026 14:19:28 +0000 Subject: [PATCH 01/15] feat: extract Slack message audio/image attachments for content pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users attach audio or image files to their Slack message that triggers the Recoup Content Agent, those attachments are now extracted, uploaded to Vercel Blob storage, and passed through to the content creation pipeline. - Audio attachments replace the song selection from Git - Image attachments replace the face-guide from the artist's repo New files: - extractMessageAttachments.ts — extracts and uploads Slack attachments - extractMessageAttachments.test.ts — 9 tests for attachment extraction Modified files: - registerOnNewMention.ts — calls extractMessageAttachments, passes URLs - triggerCreateContent.ts — adds attachedAudioUrl/attachedImageUrl to payload - validateCreateContentBody.ts — accepts attached_audio_url/attached_image_url - createContentHandler.ts — passes attachment URLs through to trigger Co-Authored-By: Paperclip --- .../extractMessageAttachments.test.ts | 185 ++++++++++++++++++ .../__tests__/registerOnNewMention.test.ts | 133 +++++++++++++ .../content/extractMessageAttachments.ts | 67 +++++++ .../content/handlers/registerOnNewMention.ts | 12 ++ lib/content/createContentHandler.ts | 4 + lib/content/validateCreateContentBody.ts | 8 + lib/trigger/triggerCreateContent.ts | 6 + 7 files changed, 415 insertions(+) create mode 100644 lib/agents/content/__tests__/extractMessageAttachments.test.ts create mode 100644 lib/agents/content/extractMessageAttachments.ts diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts new file mode 100644 index 00000000..fa6ebd54 --- /dev/null +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { extractMessageAttachments } from "../extractMessageAttachments"; + +vi.mock("@vercel/blob", () => ({ + put: vi.fn(), +})); + +const { put } = await import("@vercel/blob"); + +describe("extractMessageAttachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null values when message has no attachments", async () => { + const message = { text: "hello", attachments: [] }; + const result = await extractMessageAttachments(message as never); + expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); + }); + + it("returns null values when attachments is undefined", async () => { + const message = { text: "hello" }; + const result = await extractMessageAttachments(message as never); + expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); + }); + + it("extracts and uploads an audio attachment", async () => { + const audioBuffer = Buffer.from("fake-audio-data"); + const message = { + text: "hello", + attachments: [ + { + type: "audio", + name: "my-song.mp3", + fetchData: vi.fn().mockResolvedValue(audioBuffer), + }, + ], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/content-attachments/my-song.mp3", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(message.attachments[0].fetchData).toHaveBeenCalled(); + expect(put).toHaveBeenCalledWith(expect.stringContaining("my-song.mp3"), audioBuffer, { + access: "public", + }); + expect(result.attachedAudioUrl).toBe( + "https://blob.vercel-storage.com/content-attachments/my-song.mp3", + ); + expect(result.attachedImageUrl).toBeNull(); + }); + + it("extracts and uploads an image attachment", async () => { + const imageBuffer = Buffer.from("fake-image-data"); + const message = { + text: "hello", + attachments: [ + { + type: "image", + name: "face.png", + fetchData: vi.fn().mockResolvedValue(imageBuffer), + }, + ], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/content-attachments/face.png", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(result.attachedImageUrl).toBe( + "https://blob.vercel-storage.com/content-attachments/face.png", + ); + expect(result.attachedAudioUrl).toBeNull(); + }); + + it("extracts both audio and image when both are attached", async () => { + const audioBuffer = Buffer.from("fake-audio"); + const imageBuffer = Buffer.from("fake-image"); + const message = { + text: "hello", + attachments: [ + { + type: "audio", + name: "song.mp3", + fetchData: vi.fn().mockResolvedValue(audioBuffer), + }, + { + type: "image", + name: "photo.jpg", + fetchData: vi.fn().mockResolvedValue(imageBuffer), + }, + ], + }; + vi.mocked(put).mockResolvedValueOnce({ + url: "https://blob.vercel-storage.com/song.mp3", + } as never); + vi.mocked(put).mockResolvedValueOnce({ + url: "https://blob.vercel-storage.com/photo.jpg", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(result.attachedAudioUrl).toBe("https://blob.vercel-storage.com/song.mp3"); + expect(result.attachedImageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + }); + + it("uses attachment data buffer if fetchData is not available", async () => { + const audioBuffer = Buffer.from("inline-audio"); + const message = { + text: "hello", + attachments: [ + { + type: "audio", + name: "inline.mp3", + data: audioBuffer, + }, + ], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/inline.mp3", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(put).toHaveBeenCalledWith(expect.stringContaining("inline.mp3"), audioBuffer, { + access: "public", + }); + expect(result.attachedAudioUrl).toBe("https://blob.vercel-storage.com/inline.mp3"); + }); + + it("uses first audio and first image when multiple of same type exist", async () => { + const audio1 = Buffer.from("audio1"); + const audio2 = Buffer.from("audio2"); + const message = { + text: "hello", + attachments: [ + { type: "audio", name: "first.mp3", fetchData: vi.fn().mockResolvedValue(audio1) }, + { type: "audio", name: "second.mp3", fetchData: vi.fn().mockResolvedValue(audio2) }, + ], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/first.mp3", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(put).toHaveBeenCalledTimes(1); + expect(result.attachedAudioUrl).toBe("https://blob.vercel-storage.com/first.mp3"); + }); + + it("ignores file attachments that are not audio or image", async () => { + const message = { + text: "hello", + attachments: [ + { type: "file", name: "document.pdf", fetchData: vi.fn() }, + { type: "video", name: "clip.mp4", fetchData: vi.fn() }, + ], + }; + + const result = await extractMessageAttachments(message as never); + + expect(put).not.toHaveBeenCalled(); + expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); + }); + + it("falls back to generic name when attachment name is missing", async () => { + const audioBuffer = Buffer.from("audio"); + const message = { + text: "hello", + attachments: [{ type: "audio", fetchData: vi.fn().mockResolvedValue(audioBuffer) }], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/attachment", + } as never); + + await extractMessageAttachments(message as never); + + expect(put).toHaveBeenCalledWith(expect.stringContaining("attachment"), audioBuffer, { + access: "public", + }); + }); +}); diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index 9f1a147c..b5d04ce0 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -29,11 +29,16 @@ vi.mock("../parseContentPrompt", () => ({ parseContentPrompt: vi.fn(), })); +vi.mock("../extractMessageAttachments", () => ({ + extractMessageAttachments: vi.fn(), +})); + const { triggerCreateContent } = await import("@/lib/trigger/triggerCreateContent"); const { triggerPollContentRun } = await import("@/lib/trigger/triggerPollContentRun"); const { resolveArtistSlug } = await import("@/lib/content/resolveArtistSlug"); const { getArtistContentReadiness } = await import("@/lib/content/getArtistContentReadiness"); const { parseContentPrompt } = await import("../parseContentPrompt"); +const { extractMessageAttachments } = await import("../extractMessageAttachments"); /** * Creates a mock content agent bot for testing. @@ -76,6 +81,10 @@ function createMockMessage(text: string) { describe("registerOnNewMention", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(extractMessageAttachments).mockResolvedValue({ + attachedAudioUrl: null, + attachedImageUrl: null, + }); }); it("registers a handler on the bot", () => { @@ -273,6 +282,130 @@ describe("registerOnNewMention", () => { expect(ackMessage).toContain("Videos:"); }); + it("passes attachedAudioUrl to triggerCreateContent when audio is attached", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(extractMessageAttachments).mockResolvedValue({ + attachedAudioUrl: "https://blob.vercel-storage.com/song.mp3", + attachedImageUrl: null, + }); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a video"); + await bot.getHandler()!(thread, message); + + expect(triggerCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + attachedAudioUrl: "https://blob.vercel-storage.com/song.mp3", + }), + ); + }); + + it("passes attachedImageUrl to triggerCreateContent when image is attached", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(extractMessageAttachments).mockResolvedValue({ + attachedAudioUrl: null, + attachedImageUrl: "https://blob.vercel-storage.com/face.png", + }); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a video"); + await bot.getHandler()!(thread, message); + + expect(triggerCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + attachedImageUrl: "https://blob.vercel-storage.com/face.png", + }), + ); + }); + + it("omits attachment URLs from payload when no media is attached", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a video"); + await bot.getHandler()!(thread, message); + + const payload = vi.mocked(triggerCreateContent).mock.calls[0][0]; + expect(payload).not.toHaveProperty("attachedAudioUrl"); + expect(payload).not.toHaveProperty("attachedImageUrl"); + }); + + it("includes attached media notes in acknowledgment message", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(extractMessageAttachments).mockResolvedValue({ + attachedAudioUrl: "https://blob.vercel-storage.com/song.mp3", + attachedImageUrl: "https://blob.vercel-storage.com/face.png", + }); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a video"); + await bot.getHandler()!(thread, message); + + const ackMessage = thread.post.mock.calls[0][0] as string; + expect(ackMessage).toContain("Audio: attached file"); + expect(ackMessage).toContain("Image: attached file"); + }); + it("includes song names in acknowledgment message", async () => { const bot = createMockBot(); registerOnNewMention(bot as never); diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts new file mode 100644 index 00000000..3b9282da --- /dev/null +++ b/lib/agents/content/extractMessageAttachments.ts @@ -0,0 +1,67 @@ +import { put } from "@vercel/blob"; + +interface Attachment { + type: "image" | "file" | "video" | "audio"; + name?: string; + data?: Buffer | Blob; + fetchData?: () => Promise; +} + +interface MessageWithAttachments { + attachments?: Attachment[]; +} + +export interface ExtractedAttachments { + attachedAudioUrl: string | null; + attachedImageUrl: string | null; +} + +/** + * Extracts audio and image attachments from a Slack message, uploads them + * to Vercel Blob storage, and returns public URLs for the content pipeline. + * + * @param message - The chat message with optional attachments + * @returns Public URLs for the first audio and first image attachment found + */ +export async function extractMessageAttachments( + message: MessageWithAttachments, +): Promise { + const result: ExtractedAttachments = { + attachedAudioUrl: null, + attachedImageUrl: null, + }; + + const attachments = message.attachments; + if (!attachments || attachments.length === 0) { + return result; + } + + const audioAttachment = attachments.find(a => a.type === "audio"); + const imageAttachment = attachments.find(a => a.type === "image"); + + if (audioAttachment) { + result.attachedAudioUrl = await uploadAttachment(audioAttachment, "audio"); + } + + if (imageAttachment) { + result.attachedImageUrl = await uploadAttachment(imageAttachment, "image"); + } + + return result; +} + +/** + * Downloads attachment data and uploads it to Vercel Blob storage. + * + * @param attachment + * @param prefix + */ +async function uploadAttachment(attachment: Attachment, prefix: string): Promise { + const data = attachment.fetchData ? await attachment.fetchData() : (attachment.data as Buffer); + + const filename = attachment.name ?? "attachment"; + const blobPath = `content-attachments/${prefix}/${filename}`; + + const blob = await put(blobPath, data, { access: "public" }); + return blob.url; +} diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index d5e4b706..b66e6787 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -5,6 +5,7 @@ import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { parseContentPrompt } from "../parseContentPrompt"; +import { extractMessageAttachments } from "../extractMessageAttachments"; /** * Registers the onNewMention handler on the content agent bot. @@ -25,6 +26,9 @@ export function registerOnNewMention(bot: ContentAgentBot) { message.text, ); + // Extract audio/image attachments from the Slack message + const { attachedAudioUrl, attachedImageUrl } = await extractMessageAttachments(message); + // Resolve artist slug const artistSlug = await resolveArtistSlug(artistAccountId); if (!artistSlug) { @@ -65,6 +69,12 @@ export function registerOnNewMention(bot: ContentAgentBot) { if (songs && songs.length > 0) { details.push(`- Songs: ${songs.join(", ")}`); } + if (attachedAudioUrl) { + details.push("- Audio: attached file"); + } + if (attachedImageUrl) { + details.push("- Image: attached file (face guide)"); + } await thread.post( `Generating content...\n${details.join("\n")}\n\nI'll reply here when ready (~5-10 min).`, ); @@ -79,6 +89,8 @@ export function registerOnNewMention(bot: ContentAgentBot) { upscale, githubRepo, ...(songs && songs.length > 0 && { songs }), + ...(attachedAudioUrl && { attachedAudioUrl }), + ...(attachedImageUrl && { attachedImageUrl }), }; const results = await Promise.allSettled( diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 5ee90515..4130a0fd 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -9,6 +9,8 @@ import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectA /** * Handler for POST /api/content/create. * Always returns runIds array (KISS — one response shape for single and batch). + * + * @param request */ export async function createContentHandler(request: NextRequest): Promise { const validated = await validateCreateContentBody(request); @@ -50,6 +52,8 @@ export async function createContentHandler(request: NextRequest): Promise Date: Tue, 31 Mar 2026 11:45:41 -0500 Subject: [PATCH 02/15] fix: add data guard, error handling, and unique paths in extractMessageAttachments Address CodeRabbit review feedback: - Guard against undefined attachment data - Gracefully handle upload failures without crashing the handler - Add timestamp prefix to blob paths to prevent filename collisions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractMessageAttachments.test.ts | 47 +++++++++++++++++++ .../content/extractMessageAttachments.ts | 23 +++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index fa6ebd54..4774cca6 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -166,6 +166,53 @@ describe("extractMessageAttachments", () => { expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); }); + it("returns null when attachment has no data and no fetchData", async () => { + const message = { + text: "hello", + attachments: [ + { + type: "audio", + name: "empty.mp3", + }, + ], + }; + + const result = await extractMessageAttachments(message as never); + + expect(put).not.toHaveBeenCalled(); + expect(result.attachedAudioUrl).toBeNull(); + }); + + it("gracefully handles upload failure without crashing", async () => { + const audioBuffer = Buffer.from("fake-audio"); + const imageBuffer = Buffer.from("fake-image"); + const message = { + text: "hello", + attachments: [ + { + type: "audio", + name: "song.mp3", + fetchData: vi.fn().mockResolvedValue(audioBuffer), + }, + { + type: "image", + name: "photo.jpg", + fetchData: vi.fn().mockResolvedValue(imageBuffer), + }, + ], + }; + // First call (audio) throws, second call (image) succeeds + vi.mocked(put).mockRejectedValueOnce(new Error("upload failed")); + vi.mocked(put).mockResolvedValueOnce({ + url: "https://blob.vercel-storage.com/photo.jpg", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(result.attachedAudioUrl).toBeNull(); + expect(result.attachedImageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + }); + it("falls back to generic name when attachment name is missing", async () => { const audioBuffer = Buffer.from("audio"); const message = { diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 3b9282da..4728a623 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -40,11 +40,19 @@ export async function extractMessageAttachments( const imageAttachment = attachments.find(a => a.type === "image"); if (audioAttachment) { - result.attachedAudioUrl = await uploadAttachment(audioAttachment, "audio"); + try { + result.attachedAudioUrl = await uploadAttachment(audioAttachment, "audio"); + } catch (error) { + console.error("[content-agent] Failed to upload audio attachment:", error); + } } if (imageAttachment) { - result.attachedImageUrl = await uploadAttachment(imageAttachment, "image"); + try { + result.attachedImageUrl = await uploadAttachment(imageAttachment, "image"); + } catch (error) { + console.error("[content-agent] Failed to upload image attachment:", error); + } } return result; @@ -56,11 +64,16 @@ export async function extractMessageAttachments( * @param attachment * @param prefix */ -async function uploadAttachment(attachment: Attachment, prefix: string): Promise { - const data = attachment.fetchData ? await attachment.fetchData() : (attachment.data as Buffer); +async function uploadAttachment(attachment: Attachment, prefix: string): Promise { + const data = attachment.fetchData ? await attachment.fetchData() : attachment.data; + + if (!data) { + console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no data`); + return null; + } const filename = attachment.name ?? "attachment"; - const blobPath = `content-attachments/${prefix}/${filename}`; + const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; const blob = await put(blobPath, data, { access: "public" }); return blob.url; From 2aa251aa68882fb6efa77c47e88ab360184fd938 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 14:22:59 -0500 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20align=20with=20docs/tasks=20?= =?UTF-8?q?=E2=80=94=20songs=20accepts=20URLs,=20images=20array=20replaces?= =?UTF-8?q?=20attachedAudioUrl/attachedImageUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractMessageAttachments returns songUrl/imageUrl (not attachedAudioUrl/attachedImageUrl) - registerOnNewMention merges songUrl into songs array, imageUrl into images array - validateCreateContentBody accepts images array, removes attached_audio_url/attached_image_url - triggerCreateContent payload uses songs/images (matches tasks schema) - createContentHandler passes images array TDD: 1659/1659 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractMessageAttachments.test.ts | 28 +++++++-------- .../__tests__/registerOnNewMention.test.ts | 34 ++++++++----------- .../content/extractMessageAttachments.ts | 12 +++---- .../content/handlers/registerOnNewMention.ts | 14 ++++---- lib/content/createContentHandler.ts | 3 +- lib/content/validateCreateContentBody.ts | 9 ++--- lib/trigger/triggerCreateContent.ts | 8 ++--- 7 files changed, 50 insertions(+), 58 deletions(-) diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index 4774cca6..8dbbacf3 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -15,13 +15,13 @@ describe("extractMessageAttachments", () => { it("returns null values when message has no attachments", async () => { const message = { text: "hello", attachments: [] }; const result = await extractMessageAttachments(message as never); - expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); + expect(result).toEqual({ songUrl: null, imageUrl: null }); }); it("returns null values when attachments is undefined", async () => { const message = { text: "hello" }; const result = await extractMessageAttachments(message as never); - expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); + expect(result).toEqual({ songUrl: null, imageUrl: null }); }); it("extracts and uploads an audio attachment", async () => { @@ -46,10 +46,10 @@ describe("extractMessageAttachments", () => { expect(put).toHaveBeenCalledWith(expect.stringContaining("my-song.mp3"), audioBuffer, { access: "public", }); - expect(result.attachedAudioUrl).toBe( + expect(result.songUrl).toBe( "https://blob.vercel-storage.com/content-attachments/my-song.mp3", ); - expect(result.attachedImageUrl).toBeNull(); + expect(result.imageUrl).toBeNull(); }); it("extracts and uploads an image attachment", async () => { @@ -70,10 +70,10 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); - expect(result.attachedImageUrl).toBe( + expect(result.imageUrl).toBe( "https://blob.vercel-storage.com/content-attachments/face.png", ); - expect(result.attachedAudioUrl).toBeNull(); + expect(result.songUrl).toBeNull(); }); it("extracts both audio and image when both are attached", async () => { @@ -103,8 +103,8 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); - expect(result.attachedAudioUrl).toBe("https://blob.vercel-storage.com/song.mp3"); - expect(result.attachedImageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + expect(result.songUrl).toBe("https://blob.vercel-storage.com/song.mp3"); + expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); }); it("uses attachment data buffer if fetchData is not available", async () => { @@ -128,7 +128,7 @@ describe("extractMessageAttachments", () => { expect(put).toHaveBeenCalledWith(expect.stringContaining("inline.mp3"), audioBuffer, { access: "public", }); - expect(result.attachedAudioUrl).toBe("https://blob.vercel-storage.com/inline.mp3"); + expect(result.songUrl).toBe("https://blob.vercel-storage.com/inline.mp3"); }); it("uses first audio and first image when multiple of same type exist", async () => { @@ -148,7 +148,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); expect(put).toHaveBeenCalledTimes(1); - expect(result.attachedAudioUrl).toBe("https://blob.vercel-storage.com/first.mp3"); + expect(result.songUrl).toBe("https://blob.vercel-storage.com/first.mp3"); }); it("ignores file attachments that are not audio or image", async () => { @@ -163,7 +163,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); expect(put).not.toHaveBeenCalled(); - expect(result).toEqual({ attachedAudioUrl: null, attachedImageUrl: null }); + expect(result).toEqual({ songUrl: null, imageUrl: null }); }); it("returns null when attachment has no data and no fetchData", async () => { @@ -180,7 +180,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); expect(put).not.toHaveBeenCalled(); - expect(result.attachedAudioUrl).toBeNull(); + expect(result.songUrl).toBeNull(); }); it("gracefully handles upload failure without crashing", async () => { @@ -209,8 +209,8 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); - expect(result.attachedAudioUrl).toBeNull(); - expect(result.attachedImageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + expect(result.songUrl).toBeNull(); + expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); }); it("falls back to generic name when attachment name is missing", async () => { diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index b5d04ce0..aedd578d 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -82,8 +82,8 @@ describe("registerOnNewMention", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(extractMessageAttachments).mockResolvedValue({ - attachedAudioUrl: null, - attachedImageUrl: null, + songUrl: null, + imageUrl: null, }); }); @@ -282,7 +282,7 @@ describe("registerOnNewMention", () => { expect(ackMessage).toContain("Videos:"); }); - it("passes attachedAudioUrl to triggerCreateContent when audio is attached", async () => { + it("adds song URL to songs array when audio is attached", async () => { const bot = createMockBot(); registerOnNewMention(bot as never); @@ -298,8 +298,8 @@ describe("registerOnNewMention", () => { githubRepo: "https://github.com/test/repo", } as never); vi.mocked(extractMessageAttachments).mockResolvedValue({ - attachedAudioUrl: "https://blob.vercel-storage.com/song.mp3", - attachedImageUrl: null, + songUrl: "https://blob.vercel-storage.com/song.mp3", + imageUrl: null, }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); @@ -308,14 +308,11 @@ describe("registerOnNewMention", () => { const message = createMockMessage("make a video"); await bot.getHandler()!(thread, message); - expect(triggerCreateContent).toHaveBeenCalledWith( - expect.objectContaining({ - attachedAudioUrl: "https://blob.vercel-storage.com/song.mp3", - }), - ); + const payload = vi.mocked(triggerCreateContent).mock.calls[0][0]; + expect(payload.songs).toContain("https://blob.vercel-storage.com/song.mp3"); }); - it("passes attachedImageUrl to triggerCreateContent when image is attached", async () => { + it("passes images array to triggerCreateContent when image is attached", async () => { const bot = createMockBot(); registerOnNewMention(bot as never); @@ -331,8 +328,8 @@ describe("registerOnNewMention", () => { githubRepo: "https://github.com/test/repo", } as never); vi.mocked(extractMessageAttachments).mockResolvedValue({ - attachedAudioUrl: null, - attachedImageUrl: "https://blob.vercel-storage.com/face.png", + songUrl: null, + imageUrl: "https://blob.vercel-storage.com/face.png", }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); @@ -343,12 +340,12 @@ describe("registerOnNewMention", () => { expect(triggerCreateContent).toHaveBeenCalledWith( expect.objectContaining({ - attachedImageUrl: "https://blob.vercel-storage.com/face.png", + images: ["https://blob.vercel-storage.com/face.png"], }), ); }); - it("omits attachment URLs from payload when no media is attached", async () => { + it("omits images from payload when no media is attached", async () => { const bot = createMockBot(); registerOnNewMention(bot as never); @@ -371,8 +368,7 @@ describe("registerOnNewMention", () => { await bot.getHandler()!(thread, message); const payload = vi.mocked(triggerCreateContent).mock.calls[0][0]; - expect(payload).not.toHaveProperty("attachedAudioUrl"); - expect(payload).not.toHaveProperty("attachedImageUrl"); + expect(payload).not.toHaveProperty("images"); }); it("includes attached media notes in acknowledgment message", async () => { @@ -391,8 +387,8 @@ describe("registerOnNewMention", () => { githubRepo: "https://github.com/test/repo", } as never); vi.mocked(extractMessageAttachments).mockResolvedValue({ - attachedAudioUrl: "https://blob.vercel-storage.com/song.mp3", - attachedImageUrl: "https://blob.vercel-storage.com/face.png", + songUrl: "https://blob.vercel-storage.com/song.mp3", + imageUrl: "https://blob.vercel-storage.com/face.png", }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 4728a623..cc9d03ea 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -12,8 +12,8 @@ interface MessageWithAttachments { } export interface ExtractedAttachments { - attachedAudioUrl: string | null; - attachedImageUrl: string | null; + songUrl: string | null; + imageUrl: string | null; } /** @@ -27,8 +27,8 @@ export async function extractMessageAttachments( message: MessageWithAttachments, ): Promise { const result: ExtractedAttachments = { - attachedAudioUrl: null, - attachedImageUrl: null, + songUrl: null, + imageUrl: null, }; const attachments = message.attachments; @@ -41,7 +41,7 @@ export async function extractMessageAttachments( if (audioAttachment) { try { - result.attachedAudioUrl = await uploadAttachment(audioAttachment, "audio"); + result.songUrl = await uploadAttachment(audioAttachment, "audio"); } catch (error) { console.error("[content-agent] Failed to upload audio attachment:", error); } @@ -49,7 +49,7 @@ export async function extractMessageAttachments( if (imageAttachment) { try { - result.attachedImageUrl = await uploadAttachment(imageAttachment, "image"); + result.imageUrl = await uploadAttachment(imageAttachment, "image"); } catch (error) { console.error("[content-agent] Failed to upload image attachment:", error); } diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index b66e6787..09236116 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -27,7 +27,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { ); // Extract audio/image attachments from the Slack message - const { attachedAudioUrl, attachedImageUrl } = await extractMessageAttachments(message); + const { songUrl, imageUrl } = await extractMessageAttachments(message); // Resolve artist slug const artistSlug = await resolveArtistSlug(artistAccountId); @@ -69,16 +69,19 @@ export function registerOnNewMention(bot: ContentAgentBot) { if (songs && songs.length > 0) { details.push(`- Songs: ${songs.join(", ")}`); } - if (attachedAudioUrl) { + if (songUrl) { details.push("- Audio: attached file"); } - if (attachedImageUrl) { + if (imageUrl) { details.push("- Image: attached file (face guide)"); } await thread.post( `Generating content...\n${details.join("\n")}\n\nI'll reply here when ready (~5-10 min).`, ); + // Build songs array: merge parsed slugs with attached audio URL + const allSongs = [...(songs ?? []), ...(songUrl ? [songUrl] : [])]; + // Trigger content creation const payload = { accountId, @@ -88,9 +91,8 @@ export function registerOnNewMention(bot: ContentAgentBot) { captionLength, upscale, githubRepo, - ...(songs && songs.length > 0 && { songs }), - ...(attachedAudioUrl && { attachedAudioUrl }), - ...(attachedImageUrl && { attachedImageUrl }), + ...(allSongs.length > 0 && { songs: allSongs }), + ...(imageUrl && { images: [imageUrl] }), }; const results = await Promise.allSettled( diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 4130a0fd..6a074554 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -52,8 +52,7 @@ export async function createContentHandler(request: NextRequest): Promise 0 && { images: validated.images }), }; // Always use allSettled — works for single and batch. diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 666ea501..80049ce1 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -27,8 +27,7 @@ export const createContentBodySchema = z.object({ upscale: z.boolean().optional().default(false), batch: z.number().int().min(1).max(30).optional().default(1), songs: songsSchema, - attached_audio_url: z.string().url().optional(), - attached_image_url: z.string().url().optional(), + images: z.array(z.string().url()).optional(), }); export type ValidatedCreateContentBody = { @@ -41,8 +40,7 @@ export type ValidatedCreateContentBody = { upscale: boolean; batch: number; songs?: string[]; - attachedAudioUrl?: string; - attachedImageUrl?: string; + images?: string[]; }; /** @@ -104,7 +102,6 @@ export async function validateCreateContentBody( upscale: result.data.upscale ?? false, batch: result.data.batch ?? 1, songs: result.data.songs, - attachedAudioUrl: result.data.attached_audio_url, - attachedImageUrl: result.data.attached_image_url, + images: result.data.images, }; } diff --git a/lib/trigger/triggerCreateContent.ts b/lib/trigger/triggerCreateContent.ts index f0e547b2..9d6e5bd8 100644 --- a/lib/trigger/triggerCreateContent.ts +++ b/lib/trigger/triggerCreateContent.ts @@ -12,12 +12,10 @@ export interface TriggerCreateContentPayload { upscale: boolean; /** GitHub repo URL so the task can fetch artist files. */ githubRepo: string; - /** Optional list of song slugs to restrict which songs the pipeline picks from. */ + /** Optional list of song slugs or public URLs. URLs are downloaded directly by the task. */ songs?: string[]; - /** Public URL of a user-attached audio file to use instead of selecting from Git songs. */ - attachedAudioUrl?: string; - /** Public URL of a user-attached image to use as the face guide instead of the repo face-guide.png. */ - attachedImageUrl?: string; + /** Optional list of public image URLs to use as face guides. */ + images?: string[]; } /** From 50d4c7f79a043e1a488b7a9bc2566c7ae1ec1f11 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 14:37:05 -0500 Subject: [PATCH 04/15] fix: detect Slack file uploads by mimeType, not just attachment type Slack classifies uploaded images/audio as type: "file", not "image"/"audio". Check mimeType (e.g. "image/jpeg", "audio/mpeg") to correctly detect media from Slack file uploads. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractMessageAttachments.test.ts | 44 +++++++++++++++++++ .../content/extractMessageAttachments.ts | 8 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index 8dbbacf3..3ab5bd28 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -151,6 +151,50 @@ describe("extractMessageAttachments", () => { expect(result.songUrl).toBe("https://blob.vercel-storage.com/first.mp3"); }); + it("detects image from file type with image mimeType (Slack uploads)", async () => { + const imageBuffer = Buffer.from("fake-image"); + const message = { + text: "hello", + attachments: [ + { + type: "file", + name: "photo.jpg", + mimeType: "image/jpeg", + fetchData: vi.fn().mockResolvedValue(imageBuffer), + }, + ], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/photo.jpg", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + }); + + it("detects audio from file type with audio mimeType (Slack uploads)", async () => { + const audioBuffer = Buffer.from("fake-audio"); + const message = { + text: "hello", + attachments: [ + { + type: "file", + name: "song.mp3", + mimeType: "audio/mpeg", + fetchData: vi.fn().mockResolvedValue(audioBuffer), + }, + ], + }; + vi.mocked(put).mockResolvedValue({ + url: "https://blob.vercel-storage.com/song.mp3", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(result.songUrl).toBe("https://blob.vercel-storage.com/song.mp3"); + }); + it("ignores file attachments that are not audio or image", async () => { const message = { text: "hello", diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index cc9d03ea..9f32e586 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -2,6 +2,7 @@ import { put } from "@vercel/blob"; interface Attachment { type: "image" | "file" | "video" | "audio"; + mimeType?: string; name?: string; data?: Buffer | Blob; fetchData?: () => Promise; @@ -36,8 +37,11 @@ export async function extractMessageAttachments( return result; } - const audioAttachment = attachments.find(a => a.type === "audio"); - const imageAttachment = attachments.find(a => a.type === "image"); + const isAudio = (a: Attachment) => a.type === "audio" || a.mimeType?.startsWith("audio/"); + const isImage = (a: Attachment) => a.type === "image" || a.mimeType?.startsWith("image/"); + + const audioAttachment = attachments.find(isAudio); + const imageAttachment = attachments.find(isImage); if (audioAttachment) { try { From 7365ff27a6ff3424d65547b040e37a11e4b5426f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 14:45:42 -0500 Subject: [PATCH 05/15] debug: add logging to uploadAttachment to diagnose Blob corruption Log attachment metadata (type, name, mimeType, url, fetchData presence), fetched data size, and uploaded Blob URL to trace why files are corrupt. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/extractMessageAttachments.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 9f32e586..0b6e0238 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -69,6 +69,8 @@ export async function extractMessageAttachments( * @param prefix */ async function uploadAttachment(attachment: Attachment, prefix: string): Promise { + console.log(`[content-agent] uploadAttachment: type=${attachment.type}, name=${attachment.name}, mimeType=${attachment.mimeType}, url=${attachment.url}, hasFetchData=${!!attachment.fetchData}, hasData=${!!attachment.data}`); + const data = attachment.fetchData ? await attachment.fetchData() : attachment.data; if (!data) { @@ -76,9 +78,14 @@ async function uploadAttachment(attachment: Attachment, prefix: string): Promise return null; } + const isBuffer = Buffer.isBuffer(data); + const size = isBuffer ? (data as Buffer).byteLength : (data as Blob).size; + console.log(`[content-agent] uploadAttachment: fetched data, isBuffer=${isBuffer}, size=${size}`); + const filename = attachment.name ?? "attachment"; const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; const blob = await put(blobPath, data, { access: "public" }); + console.log(`[content-agent] uploadAttachment: uploaded to ${blob.url}`); return blob.url; } From 08472b7b260c98852fc5ee3c31956974f0ccaac5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 14:47:46 -0500 Subject: [PATCH 06/15] fix: add url to Attachment interface for debug logging Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/extractMessageAttachments.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 0b6e0238..57bdec15 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -4,6 +4,7 @@ interface Attachment { type: "image" | "file" | "video" | "audio"; mimeType?: string; name?: string; + url?: string; data?: Buffer | Blob; fetchData?: () => Promise; } From 637388b0fa38fe49068d26f9eafdb47910aaabe4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 14:54:26 -0500 Subject: [PATCH 07/15] fix: use direct attachment URL instead of Blob re-upload Blob upload was corrupting both audio and image files (empty content). Now uses attachment.url directly when available, skipping the download+reupload round-trip. Falls back to Blob only when no URL exists. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractMessageAttachments.test.ts | 30 ++++++++++++- .../content/extractMessageAttachments.ts | 43 +++++++++---------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index 3ab5bd28..e8ace2d8 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -24,7 +24,35 @@ describe("extractMessageAttachments", () => { expect(result).toEqual({ songUrl: null, imageUrl: null }); }); - it("extracts and uploads an audio attachment", async () => { + it("uses direct URL when attachment has url field (skips Blob)", async () => { + const message = { + text: "hello", + attachments: [ + { + type: "audio", + name: "song.mp3", + url: "https://files.slack.com/files-pri/T123/song.mp3", + fetchData: vi.fn(), + }, + { + type: "image", + name: "face.png", + url: "https://files.slack.com/files-pri/T123/face.png", + fetchData: vi.fn(), + }, + ], + }; + + const result = await extractMessageAttachments(message as never); + + expect(result.songUrl).toBe("https://files.slack.com/files-pri/T123/song.mp3"); + expect(result.imageUrl).toBe("https://files.slack.com/files-pri/T123/face.png"); + expect(put).not.toHaveBeenCalled(); + expect(message.attachments[0].fetchData).not.toHaveBeenCalled(); + expect(message.attachments[1].fetchData).not.toHaveBeenCalled(); + }); + + it("falls back to Blob upload when no url field", async () => { const audioBuffer = Buffer.from("fake-audio-data"); const message = { text: "hello", diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 57bdec15..9fe78db2 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -19,11 +19,10 @@ export interface ExtractedAttachments { } /** - * Extracts audio and image attachments from a Slack message, uploads them - * to Vercel Blob storage, and returns public URLs for the content pipeline. - * - * @param message - The chat message with optional attachments - * @returns Public URLs for the first audio and first image attachment found + * Extracts audio and image attachments from a Slack message and returns + * public URLs for the content pipeline. Prefers the attachment's direct URL + * when available; falls back to downloading via fetchData and re-uploading + * to Vercel Blob storage. */ export async function extractMessageAttachments( message: MessageWithAttachments, @@ -46,17 +45,17 @@ export async function extractMessageAttachments( if (audioAttachment) { try { - result.songUrl = await uploadAttachment(audioAttachment, "audio"); + result.songUrl = await resolveAttachmentUrl(audioAttachment, "audio"); } catch (error) { - console.error("[content-agent] Failed to upload audio attachment:", error); + console.error("[content-agent] Failed to resolve audio attachment:", error); } } if (imageAttachment) { try { - result.imageUrl = await uploadAttachment(imageAttachment, "image"); + result.imageUrl = await resolveAttachmentUrl(imageAttachment, "image"); } catch (error) { - console.error("[content-agent] Failed to upload image attachment:", error); + console.error("[content-agent] Failed to resolve image attachment:", error); } } @@ -64,29 +63,27 @@ export async function extractMessageAttachments( } /** - * Downloads attachment data and uploads it to Vercel Blob storage. - * - * @param attachment - * @param prefix + * Resolves a public URL for an attachment. Uses the attachment's direct URL + * if available (avoids re-upload corruption). Falls back to fetchData + Blob + * upload for platforms with private URLs. */ -async function uploadAttachment(attachment: Attachment, prefix: string): Promise { - console.log(`[content-agent] uploadAttachment: type=${attachment.type}, name=${attachment.name}, mimeType=${attachment.mimeType}, url=${attachment.url}, hasFetchData=${!!attachment.fetchData}, hasData=${!!attachment.data}`); +async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Promise { + // Prefer direct URL — avoids download+reupload corruption + if (attachment.url) { + console.log(`[content-agent] Using direct attachment URL: ${attachment.url}`); + return attachment.url; + } + // Fallback: download and upload to Blob const data = attachment.fetchData ? await attachment.fetchData() : attachment.data; - if (!data) { - console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no data`); + console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no URL or data`); return null; } - const isBuffer = Buffer.isBuffer(data); - const size = isBuffer ? (data as Buffer).byteLength : (data as Blob).size; - console.log(`[content-agent] uploadAttachment: fetched data, isBuffer=${isBuffer}, size=${size}`); - const filename = attachment.name ?? "attachment"; const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; - const blob = await put(blobPath, data, { access: "public" }); - console.log(`[content-agent] uploadAttachment: uploaded to ${blob.url}`); + console.log(`[content-agent] Uploaded to Blob: ${blob.url}`); return blob.url; } From 34174dfbece8e0ffba3a9d2102b414df30868db6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 14:59:34 -0500 Subject: [PATCH 08/15] fix: pass contentType to Blob put() to prevent file corruption Vercel Blob was serving files with wrong content type when contentType wasn't explicitly set. Now passes attachment.mimeType (or sensible default) to put(). Slack URLs are private so we must keep the Blob upload path but with correct content type headers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extractMessageAttachments.test.ts | 33 +++---------------- .../content/extractMessageAttachments.ts | 23 +++++++------ 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index e8ace2d8..a781b3b2 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -24,35 +24,7 @@ describe("extractMessageAttachments", () => { expect(result).toEqual({ songUrl: null, imageUrl: null }); }); - it("uses direct URL when attachment has url field (skips Blob)", async () => { - const message = { - text: "hello", - attachments: [ - { - type: "audio", - name: "song.mp3", - url: "https://files.slack.com/files-pri/T123/song.mp3", - fetchData: vi.fn(), - }, - { - type: "image", - name: "face.png", - url: "https://files.slack.com/files-pri/T123/face.png", - fetchData: vi.fn(), - }, - ], - }; - - const result = await extractMessageAttachments(message as never); - - expect(result.songUrl).toBe("https://files.slack.com/files-pri/T123/song.mp3"); - expect(result.imageUrl).toBe("https://files.slack.com/files-pri/T123/face.png"); - expect(put).not.toHaveBeenCalled(); - expect(message.attachments[0].fetchData).not.toHaveBeenCalled(); - expect(message.attachments[1].fetchData).not.toHaveBeenCalled(); - }); - - it("falls back to Blob upload when no url field", async () => { + it("uploads audio with correct contentType", async () => { const audioBuffer = Buffer.from("fake-audio-data"); const message = { text: "hello", @@ -73,6 +45,7 @@ describe("extractMessageAttachments", () => { expect(message.attachments[0].fetchData).toHaveBeenCalled(); expect(put).toHaveBeenCalledWith(expect.stringContaining("my-song.mp3"), audioBuffer, { access: "public", + contentType: "audio/mpeg", }); expect(result.songUrl).toBe( "https://blob.vercel-storage.com/content-attachments/my-song.mp3", @@ -155,6 +128,7 @@ describe("extractMessageAttachments", () => { expect(put).toHaveBeenCalledWith(expect.stringContaining("inline.mp3"), audioBuffer, { access: "public", + contentType: "audio/mpeg", }); expect(result.songUrl).toBe("https://blob.vercel-storage.com/inline.mp3"); }); @@ -299,6 +273,7 @@ describe("extractMessageAttachments", () => { expect(put).toHaveBeenCalledWith(expect.stringContaining("attachment"), audioBuffer, { access: "public", + contentType: "audio/mpeg", }); }); }); diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 9fe78db2..68d0872c 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -63,27 +63,26 @@ export async function extractMessageAttachments( } /** - * Resolves a public URL for an attachment. Uses the attachment's direct URL - * if available (avoids re-upload corruption). Falls back to fetchData + Blob - * upload for platforms with private URLs. + * Resolves a public URL for an attachment. Downloads via fetchData and + * uploads to Vercel Blob with the correct content type. */ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Promise { - // Prefer direct URL — avoids download+reupload corruption - if (attachment.url) { - console.log(`[content-agent] Using direct attachment URL: ${attachment.url}`); - return attachment.url; - } - - // Fallback: download and upload to Blob const data = attachment.fetchData ? await attachment.fetchData() : attachment.data; if (!data) { - console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no URL or data`); + console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no data`); return null; } const filename = attachment.name ?? "attachment"; const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; - const blob = await put(blobPath, data, { access: "public" }); + const contentType = attachment.mimeType ?? (prefix === "audio" ? "audio/mpeg" : "image/png"); + + console.log(`[content-agent] Uploading to Blob: path=${blobPath}, contentType=${contentType}, size=${Buffer.isBuffer(data) ? data.byteLength : (data as Blob).size}`); + + const blob = await put(blobPath, data, { + access: "public", + contentType, + }); console.log(`[content-agent] Uploaded to Blob: ${blob.url}`); return blob.url; } From 0cf0e94a3007a91e2bee87f8809e4aa29e7394aa Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 15:17:04 -0500 Subject: [PATCH 09/15] fix: download Slack files with bot token instead of broken fetchData Chat SDK fetchData() returns Slack HTML login page instead of file content. Now downloads directly from attachment.url using SLACK_CONTENT_BOT_TOKEN Bearer auth, then uploads to Blob with correct contentType. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/extractMessageAttachments.ts | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 68d0872c..b996d4b7 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -63,11 +63,35 @@ export async function extractMessageAttachments( } /** - * Resolves a public URL for an attachment. Downloads via fetchData and - * uploads to Vercel Blob with the correct content type. + * Downloads a Slack file using the bot token and uploads to Vercel Blob. + * The Chat SDK's fetchData() is broken (returns HTML login page instead of file), + * so we download directly from attachment.url with the bot token. */ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Promise { - const data = attachment.fetchData ? await attachment.fetchData() : attachment.data; + let data: Buffer | null = null; + + // Download directly from Slack using bot token (fetchData returns HTML, not file data) + if (attachment.url) { + const token = process.env.SLACK_CONTENT_BOT_TOKEN; + if (token) { + const response = await fetch(attachment.url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + data = Buffer.from(await response.arrayBuffer()); + console.log(`[content-agent] Downloaded from Slack: ${attachment.url}, size=${data.byteLength}`); + } else { + console.error(`[content-agent] Slack download failed: ${response.status} ${response.statusText}`); + } + } + } + + // Fallback to fetchData / data + if (!data) { + const raw = attachment.fetchData ? await attachment.fetchData() : attachment.data; + if (raw) data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as unknown as ArrayBuffer); + } + if (!data) { console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no data`); return null; @@ -77,12 +101,7 @@ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Pro const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; const contentType = attachment.mimeType ?? (prefix === "audio" ? "audio/mpeg" : "image/png"); - console.log(`[content-agent] Uploading to Blob: path=${blobPath}, contentType=${contentType}, size=${Buffer.isBuffer(data) ? data.byteLength : (data as Blob).size}`); - - const blob = await put(blobPath, data, { - access: "public", - contentType, - }); - console.log(`[content-agent] Uploaded to Blob: ${blob.url}`); + const blob = await put(blobPath, data, { access: "public", contentType }); + console.log(`[content-agent] Uploaded to Blob: ${blob.url}, size=${data.byteLength}`); return blob.url; } From 1a3ff32fc941fa875141eb6cd3611089c06bf620 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 15:26:41 -0500 Subject: [PATCH 10/15] fix: convert Slack thumbnail URLs to download URLs for file access Slack attachment.url is a files-tmb (thumbnail) URL that returns HTML. Convert to files-pri/download URL format with bot token Bearer auth to get actual file content. Verify response isn't HTML before uploading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/extractMessageAttachments.ts | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index b996d4b7..733bb3ff 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -63,37 +63,48 @@ export async function extractMessageAttachments( } /** - * Downloads a Slack file using the bot token and uploads to Vercel Blob. - * The Chat SDK's fetchData() is broken (returns HTML login page instead of file), - * so we download directly from attachment.url with the bot token. + * Downloads a Slack file and uploads to Vercel Blob. + * + * Slack's attachment.url is a thumbnail/preview URL that returns HTML. + * We convert it to the download URL format and use the bot token for auth. + * Pattern: files-tmb/TEAM-FILEID-HASH/name → files-pri/TEAM-FILEID/download/name */ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Promise { let data: Buffer | null = null; - - // Download directly from Slack using bot token (fetchData returns HTML, not file data) - if (attachment.url) { - const token = process.env.SLACK_CONTENT_BOT_TOKEN; - if (token) { - const response = await fetch(attachment.url, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { + const token = process.env.SLACK_CONTENT_BOT_TOKEN; + + if (attachment.url && token) { + // Convert thumbnail URL to download URL + const downloadUrl = toSlackDownloadUrl(attachment.url); + console.log(`[content-agent] Downloading: ${downloadUrl}`); + + const response = await fetch(downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + redirect: "follow", + }); + + if (response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + // Verify we got actual file data, not an HTML page + if (!contentType.includes("text/html")) { data = Buffer.from(await response.arrayBuffer()); - console.log(`[content-agent] Downloaded from Slack: ${attachment.url}, size=${data.byteLength}`); + console.log(`[content-agent] Downloaded: size=${data.byteLength}, contentType=${contentType}`); } else { - console.error(`[content-agent] Slack download failed: ${response.status} ${response.statusText}`); + console.error(`[content-agent] Got HTML instead of file from: ${downloadUrl}`); } + } else { + console.error(`[content-agent] Download failed: ${response.status} ${response.statusText}`); } } - // Fallback to fetchData / data + // Fallback to fetchData / data if Slack download didn't work if (!data) { const raw = attachment.fetchData ? await attachment.fetchData() : attachment.data; if (raw) data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as unknown as ArrayBuffer); } if (!data) { - console.error(`[content-agent] Attachment "${attachment.name ?? "unknown"}" has no data`); + console.error(`[content-agent] Could not download attachment "${attachment.name ?? "unknown"}"`); return null; } @@ -105,3 +116,26 @@ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Pro console.log(`[content-agent] Uploaded to Blob: ${blob.url}, size=${data.byteLength}`); return blob.url; } + +/** + * Converts a Slack thumbnail/preview URL to the actual file download URL. + * files-tmb/TEAM-FILEID-HASH/name → files-pri/TEAM-FILEID/download/name + * files-pri/TEAM-FILEID/name → files-pri/TEAM-FILEID/download/name + */ +function toSlackDownloadUrl(url: string): string { + try { + const parsed = new URL(url); + const parts = parsed.pathname.split("/").filter(Boolean); + // parts: ["files-tmb"|"files-pri", "TEAM-FILEID-HASH", "filename"] + if (parts.length >= 3) { + const teamFileId = parts[1]; + const filename = parts[parts.length - 1]; + // Strip trailing hash from team-file ID (e.g. T06-F0AP-eecb5f → T06-F0AP) + const cleanId = teamFileId.replace(/-[a-f0-9]{10,}$/, ""); + return `https://files.slack.com/files-pri/${cleanId}/download/${filename}`; + } + } catch { + // Fall through to original URL + } + return url; +} From 64542383df8181c2c951ae02e61780d00cea690e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 15:33:14 -0500 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20correct=20Slack=20file=20URL=20con?= =?UTF-8?q?version=20=E2=80=94=20use=20files-pri=20without=20/download/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /download/ suffix caused 404s. Slack files-pri URLs work with just the team-fileID path + Bearer token auth. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/extractMessageAttachments.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 733bb3ff..0cfc06f5 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -118,24 +118,22 @@ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Pro } /** - * Converts a Slack thumbnail/preview URL to the actual file download URL. - * files-tmb/TEAM-FILEID-HASH/name → files-pri/TEAM-FILEID/download/name - * files-pri/TEAM-FILEID/name → files-pri/TEAM-FILEID/download/name + * Converts a Slack thumbnail/preview URL to the private file URL. + * files-tmb/TEAM-FILEID-HASH/name → files-pri/TEAM-FILEID/name + * Strips the trailing hash suffix from the ID segment. */ function toSlackDownloadUrl(url: string): string { try { const parsed = new URL(url); - const parts = parsed.pathname.split("/").filter(Boolean); - // parts: ["files-tmb"|"files-pri", "TEAM-FILEID-HASH", "filename"] - if (parts.length >= 3) { - const teamFileId = parts[1]; - const filename = parts[parts.length - 1]; - // Strip trailing hash from team-file ID (e.g. T06-F0AP-eecb5f → T06-F0AP) - const cleanId = teamFileId.replace(/-[a-f0-9]{10,}$/, ""); - return `https://files.slack.com/files-pri/${cleanId}/download/${filename}`; - } + // Replace files-tmb with files-pri + let path = parsed.pathname.replace("/files-tmb/", "/files-pri/"); + // Strip trailing hash from ID segment (e.g. T06-F0AP-eecb5f6c23 → T06-F0AP) + path = path.replace( + /\/files-pri\/([A-Z0-9]+-[A-Z0-9]+)-[a-f0-9]+\//, + "/files-pri/$1/", + ); + return `https://files.slack.com${path}`; } catch { - // Fall through to original URL + return url; } - return url; } From 234f7eef620da88474dc9b64e8d2bd4858872659 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 15:36:14 -0500 Subject: [PATCH 12/15] fix: use Slack files.info API to get url_private_download for file access The attachment.url from Chat SDK is a thumbnail URL (files-tmb) that doesn't serve actual file content. Now extracts the Slack file ID, calls files.info to get url_private_download, then downloads with Bearer token auth. This is the official Slack file download flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/extractMessageAttachments.ts | 117 ++++++++++++------ 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 0cfc06f5..901bf734 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -65,39 +65,23 @@ export async function extractMessageAttachments( /** * Downloads a Slack file and uploads to Vercel Blob. * - * Slack's attachment.url is a thumbnail/preview URL that returns HTML. - * We convert it to the download URL format and use the bot token for auth. - * Pattern: files-tmb/TEAM-FILEID-HASH/name → files-pri/TEAM-FILEID/download/name + * Uses Slack's files.info API to get the url_private_download URL, + * then downloads with Bearer token auth. The attachment.url from the + * Chat SDK is a thumbnail URL that doesn't serve actual file content. */ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Promise { let data: Buffer | null = null; const token = process.env.SLACK_CONTENT_BOT_TOKEN; + // Extract Slack file ID from the attachment URL and use files.info API if (attachment.url && token) { - // Convert thumbnail URL to download URL - const downloadUrl = toSlackDownloadUrl(attachment.url); - console.log(`[content-agent] Downloading: ${downloadUrl}`); - - const response = await fetch(downloadUrl, { - headers: { Authorization: `Bearer ${token}` }, - redirect: "follow", - }); - - if (response.ok) { - const contentType = response.headers.get("content-type") ?? ""; - // Verify we got actual file data, not an HTML page - if (!contentType.includes("text/html")) { - data = Buffer.from(await response.arrayBuffer()); - console.log(`[content-agent] Downloaded: size=${data.byteLength}, contentType=${contentType}`); - } else { - console.error(`[content-agent] Got HTML instead of file from: ${downloadUrl}`); - } - } else { - console.error(`[content-agent] Download failed: ${response.status} ${response.statusText}`); + const fileId = extractSlackFileId(attachment.url); + if (fileId) { + data = await downloadSlackFile(fileId, token); } } - // Fallback to fetchData / data if Slack download didn't work + // Fallback to fetchData / data if (!data) { const raw = attachment.fetchData ? await attachment.fetchData() : attachment.data; if (raw) data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as unknown as ArrayBuffer); @@ -118,22 +102,79 @@ async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Pro } /** - * Converts a Slack thumbnail/preview URL to the private file URL. - * files-tmb/TEAM-FILEID-HASH/name → files-pri/TEAM-FILEID/name - * Strips the trailing hash suffix from the ID segment. + * Extracts the Slack file ID (e.g. F0APMKTKG9M) from a Slack file URL. + * URL format: files-tmb/TEAMID-FILEID-HASH/name or files-pri/TEAMID-FILEID/name */ -function toSlackDownloadUrl(url: string): string { +function extractSlackFileId(url: string): string | null { try { - const parsed = new URL(url); - // Replace files-tmb with files-pri - let path = parsed.pathname.replace("/files-tmb/", "/files-pri/"); - // Strip trailing hash from ID segment (e.g. T06-F0AP-eecb5f6c23 → T06-F0AP) - path = path.replace( - /\/files-pri\/([A-Z0-9]+-[A-Z0-9]+)-[a-f0-9]+\//, - "/files-pri/$1/", - ); - return `https://files.slack.com${path}`; + const parts = new URL(url).pathname.split("/").filter(Boolean); + // parts[1] is "TEAMID-FILEID-HASH" or "TEAMID-FILEID" + if (parts.length >= 2) { + const segments = parts[1].split("-"); + // File ID is the second segment (e.g. F0APMKTKG9M) + if (segments.length >= 2) { + return segments[1]; + } + } } catch { - return url; + // ignore + } + return null; +} + +/** + * Downloads a file from Slack using the files.info API to get + * url_private_download, then fetches the actual file content. + */ +async function downloadSlackFile(fileId: string, token: string): Promise { + console.log(`[content-agent] Fetching file info for ${fileId}`); + + // Get url_private_download from files.info + const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!infoResponse.ok) { + console.error(`[content-agent] files.info failed: ${infoResponse.status}`); + return null; + } + + const info = (await infoResponse.json()) as { + ok: boolean; + file?: { url_private_download?: string; url_private?: string; size?: number }; + error?: string; + }; + + if (!info.ok || !info.file) { + console.error(`[content-agent] files.info error: ${info.error ?? "no file"}`); + return null; + } + + const downloadUrl = info.file.url_private_download ?? info.file.url_private; + if (!downloadUrl) { + console.error(`[content-agent] No download URL in files.info response`); + return null; + } + + console.log(`[content-agent] Downloading from: ${downloadUrl}, expectedSize=${info.file.size}`); + + // Download the actual file + const fileResponse = await fetch(downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!fileResponse.ok) { + console.error(`[content-agent] File download failed: ${fileResponse.status}`); + return null; } + + const contentType = fileResponse.headers.get("content-type") ?? ""; + if (contentType.includes("text/html")) { + console.error(`[content-agent] Got HTML instead of file content`); + return null; + } + + const data = Buffer.from(await fileResponse.arrayBuffer()); + console.log(`[content-agent] Downloaded: size=${data.byteLength}, contentType=${contentType}`); + return data; } From 3af8ed4faf0f03440c3c3fdf4d0590626d8fe7da Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 17:40:05 -0500 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20SRP=20=E2=80=94=20extract=20r?= =?UTF-8?q?esolveAttachmentUrl,=20downloadSlackFile,=20extractSlackFileId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address sweetmantech review: - SRP: each function in its own file - KISS: images passed through same as songs in createContentHandler Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/downloadSlackFile.ts | 59 +++++++++ .../content/extractMessageAttachments.ts | 125 +----------------- lib/agents/content/extractSlackFileId.ts | 22 +++ lib/agents/content/resolveAttachmentUrl.ts | 59 +++++++++ lib/content/createContentHandler.ts | 2 +- 5 files changed, 145 insertions(+), 122 deletions(-) create mode 100644 lib/agents/content/downloadSlackFile.ts create mode 100644 lib/agents/content/extractSlackFileId.ts create mode 100644 lib/agents/content/resolveAttachmentUrl.ts diff --git a/lib/agents/content/downloadSlackFile.ts b/lib/agents/content/downloadSlackFile.ts new file mode 100644 index 00000000..74466222 --- /dev/null +++ b/lib/agents/content/downloadSlackFile.ts @@ -0,0 +1,59 @@ +/** + * Downloads a file from Slack using the files.info API to get + * url_private_download, then fetches the actual file content. + * + * @param fileId + * @param token + */ +export async function downloadSlackFile(fileId: string, token: string): Promise { + console.log(`[content-agent] Fetching file info for ${fileId}`); + + // Get url_private_download from files.info + const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!infoResponse.ok) { + console.error(`[content-agent] files.info failed: ${infoResponse.status}`); + return null; + } + + const info = (await infoResponse.json()) as { + ok: boolean; + file?: { url_private_download?: string; url_private?: string; size?: number }; + error?: string; + }; + + if (!info.ok || !info.file) { + console.error(`[content-agent] files.info error: ${info.error ?? "no file"}`); + return null; + } + + const downloadUrl = info.file.url_private_download ?? info.file.url_private; + if (!downloadUrl) { + console.error(`[content-agent] No download URL in files.info response`); + return null; + } + + console.log(`[content-agent] Downloading from: ${downloadUrl}, expectedSize=${info.file.size}`); + + // Download the actual file + const fileResponse = await fetch(downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!fileResponse.ok) { + console.error(`[content-agent] File download failed: ${fileResponse.status}`); + return null; + } + + const contentType = fileResponse.headers.get("content-type") ?? ""; + if (contentType.includes("text/html")) { + console.error(`[content-agent] Got HTML instead of file content`); + return null; + } + + const data = Buffer.from(await fileResponse.arrayBuffer()); + console.log(`[content-agent] Downloaded: size=${data.byteLength}, contentType=${contentType}`); + return data; +} diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index 901bf734..de40806f 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -1,4 +1,4 @@ -import { put } from "@vercel/blob"; +import { resolveAttachmentUrl } from "./resolveAttachmentUrl"; interface Attachment { type: "image" | "file" | "video" | "audio"; @@ -20,9 +20,9 @@ export interface ExtractedAttachments { /** * Extracts audio and image attachments from a Slack message and returns - * public URLs for the content pipeline. Prefers the attachment's direct URL - * when available; falls back to downloading via fetchData and re-uploading - * to Vercel Blob storage. + * public URLs for the content pipeline. + * + * @param message */ export async function extractMessageAttachments( message: MessageWithAttachments, @@ -61,120 +61,3 @@ export async function extractMessageAttachments( return result; } - -/** - * Downloads a Slack file and uploads to Vercel Blob. - * - * Uses Slack's files.info API to get the url_private_download URL, - * then downloads with Bearer token auth. The attachment.url from the - * Chat SDK is a thumbnail URL that doesn't serve actual file content. - */ -async function resolveAttachmentUrl(attachment: Attachment, prefix: string): Promise { - let data: Buffer | null = null; - const token = process.env.SLACK_CONTENT_BOT_TOKEN; - - // Extract Slack file ID from the attachment URL and use files.info API - if (attachment.url && token) { - const fileId = extractSlackFileId(attachment.url); - if (fileId) { - data = await downloadSlackFile(fileId, token); - } - } - - // Fallback to fetchData / data - if (!data) { - const raw = attachment.fetchData ? await attachment.fetchData() : attachment.data; - if (raw) data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as unknown as ArrayBuffer); - } - - if (!data) { - console.error(`[content-agent] Could not download attachment "${attachment.name ?? "unknown"}"`); - return null; - } - - const filename = attachment.name ?? "attachment"; - const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; - const contentType = attachment.mimeType ?? (prefix === "audio" ? "audio/mpeg" : "image/png"); - - const blob = await put(blobPath, data, { access: "public", contentType }); - console.log(`[content-agent] Uploaded to Blob: ${blob.url}, size=${data.byteLength}`); - return blob.url; -} - -/** - * Extracts the Slack file ID (e.g. F0APMKTKG9M) from a Slack file URL. - * URL format: files-tmb/TEAMID-FILEID-HASH/name or files-pri/TEAMID-FILEID/name - */ -function extractSlackFileId(url: string): string | null { - try { - const parts = new URL(url).pathname.split("/").filter(Boolean); - // parts[1] is "TEAMID-FILEID-HASH" or "TEAMID-FILEID" - if (parts.length >= 2) { - const segments = parts[1].split("-"); - // File ID is the second segment (e.g. F0APMKTKG9M) - if (segments.length >= 2) { - return segments[1]; - } - } - } catch { - // ignore - } - return null; -} - -/** - * Downloads a file from Slack using the files.info API to get - * url_private_download, then fetches the actual file content. - */ -async function downloadSlackFile(fileId: string, token: string): Promise { - console.log(`[content-agent] Fetching file info for ${fileId}`); - - // Get url_private_download from files.info - const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!infoResponse.ok) { - console.error(`[content-agent] files.info failed: ${infoResponse.status}`); - return null; - } - - const info = (await infoResponse.json()) as { - ok: boolean; - file?: { url_private_download?: string; url_private?: string; size?: number }; - error?: string; - }; - - if (!info.ok || !info.file) { - console.error(`[content-agent] files.info error: ${info.error ?? "no file"}`); - return null; - } - - const downloadUrl = info.file.url_private_download ?? info.file.url_private; - if (!downloadUrl) { - console.error(`[content-agent] No download URL in files.info response`); - return null; - } - - console.log(`[content-agent] Downloading from: ${downloadUrl}, expectedSize=${info.file.size}`); - - // Download the actual file - const fileResponse = await fetch(downloadUrl, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!fileResponse.ok) { - console.error(`[content-agent] File download failed: ${fileResponse.status}`); - return null; - } - - const contentType = fileResponse.headers.get("content-type") ?? ""; - if (contentType.includes("text/html")) { - console.error(`[content-agent] Got HTML instead of file content`); - return null; - } - - const data = Buffer.from(await fileResponse.arrayBuffer()); - console.log(`[content-agent] Downloaded: size=${data.byteLength}, contentType=${contentType}`); - return data; -} diff --git a/lib/agents/content/extractSlackFileId.ts b/lib/agents/content/extractSlackFileId.ts new file mode 100644 index 00000000..0c840d98 --- /dev/null +++ b/lib/agents/content/extractSlackFileId.ts @@ -0,0 +1,22 @@ +/** + * Extracts the Slack file ID (e.g. F0APMKTKG9M) from a Slack file URL. + * URL format: files-tmb/TEAMID-FILEID-HASH/name or files-pri/TEAMID-FILEID/name + * + * @param url + */ +export function extractSlackFileId(url: string): string | null { + try { + const parts = new URL(url).pathname.split("/").filter(Boolean); + // parts[1] is "TEAMID-FILEID-HASH" or "TEAMID-FILEID" + if (parts.length >= 2) { + const segments = parts[1].split("-"); + // File ID is the second segment (e.g. F0APMKTKG9M) + if (segments.length >= 2) { + return segments[1]; + } + } + } catch { + // ignore + } + return null; +} diff --git a/lib/agents/content/resolveAttachmentUrl.ts b/lib/agents/content/resolveAttachmentUrl.ts new file mode 100644 index 00000000..2472429f --- /dev/null +++ b/lib/agents/content/resolveAttachmentUrl.ts @@ -0,0 +1,59 @@ +import { put } from "@vercel/blob"; +import { downloadSlackFile } from "./downloadSlackFile"; +import { extractSlackFileId } from "./extractSlackFileId"; + +interface Attachment { + type: "image" | "file" | "video" | "audio"; + mimeType?: string; + name?: string; + url?: string; + data?: Buffer | Blob; + fetchData?: () => Promise; +} + +/** + * Downloads a Slack file and uploads to Vercel Blob. + * + * Uses Slack's files.info API to get the url_private_download URL, + * then downloads with Bearer token auth. The attachment.url from the + * Chat SDK is a thumbnail URL that doesn't serve actual file content. + * + * @param attachment + * @param prefix + */ +export async function resolveAttachmentUrl( + attachment: Attachment, + prefix: string, +): Promise { + let data: Buffer | null = null; + const token = process.env.SLACK_CONTENT_BOT_TOKEN; + + // Extract Slack file ID from the attachment URL and use files.info API + if (attachment.url && token) { + const fileId = extractSlackFileId(attachment.url); + if (fileId) { + data = await downloadSlackFile(fileId, token); + } + } + + // Fallback to fetchData / data + if (!data) { + const raw = attachment.fetchData ? await attachment.fetchData() : attachment.data; + if (raw) data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as unknown as ArrayBuffer); + } + + if (!data) { + console.error( + `[content-agent] Could not download attachment "${attachment.name ?? "unknown"}"`, + ); + return null; + } + + const filename = attachment.name ?? "attachment"; + const blobPath = `content-attachments/${prefix}/${Date.now()}-${filename}`; + const contentType = attachment.mimeType ?? (prefix === "audio" ? "audio/mpeg" : "image/png"); + + const blob = await put(blobPath, data, { access: "public", contentType }); + console.log(`[content-agent] Uploaded to Blob: ${blob.url}, size=${data.byteLength}`); + return blob.url; +} diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 6a074554..577e8132 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -52,7 +52,7 @@ export async function createContentHandler(request: NextRequest): Promise 0 && { images: validated.images }), + images: validated.images, }; // Always use allSettled — works for single and batch. From 76da3930f53393df5bc57113eddff3b8fa2ab00a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 17:44:27 -0500 Subject: [PATCH 14/15] fix: remove dev logging, fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/extractMessageAttachments.test.ts | 8 ++------ lib/agents/content/downloadSlackFile.ts | 10 +--------- lib/agents/content/resolveAttachmentUrl.ts | 2 -- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index a781b3b2..3db6fc38 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -47,9 +47,7 @@ describe("extractMessageAttachments", () => { access: "public", contentType: "audio/mpeg", }); - expect(result.songUrl).toBe( - "https://blob.vercel-storage.com/content-attachments/my-song.mp3", - ); + expect(result.songUrl).toBe("https://blob.vercel-storage.com/content-attachments/my-song.mp3"); expect(result.imageUrl).toBeNull(); }); @@ -71,9 +69,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); - expect(result.imageUrl).toBe( - "https://blob.vercel-storage.com/content-attachments/face.png", - ); + expect(result.imageUrl).toBe("https://blob.vercel-storage.com/content-attachments/face.png"); expect(result.songUrl).toBeNull(); }); diff --git a/lib/agents/content/downloadSlackFile.ts b/lib/agents/content/downloadSlackFile.ts index 74466222..dbc0ee27 100644 --- a/lib/agents/content/downloadSlackFile.ts +++ b/lib/agents/content/downloadSlackFile.ts @@ -6,9 +6,6 @@ * @param token */ export async function downloadSlackFile(fileId: string, token: string): Promise { - console.log(`[content-agent] Fetching file info for ${fileId}`); - - // Get url_private_download from files.info const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, { headers: { Authorization: `Bearer ${token}` }, }); @@ -35,9 +32,6 @@ export async function downloadSlackFile(fileId: string, token: string): Promise< return null; } - console.log(`[content-agent] Downloading from: ${downloadUrl}, expectedSize=${info.file.size}`); - - // Download the actual file const fileResponse = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${token}` }, }); @@ -53,7 +47,5 @@ export async function downloadSlackFile(fileId: string, token: string): Promise< return null; } - const data = Buffer.from(await fileResponse.arrayBuffer()); - console.log(`[content-agent] Downloaded: size=${data.byteLength}, contentType=${contentType}`); - return data; + return Buffer.from(await fileResponse.arrayBuffer()); } diff --git a/lib/agents/content/resolveAttachmentUrl.ts b/lib/agents/content/resolveAttachmentUrl.ts index 2472429f..3912cc7f 100644 --- a/lib/agents/content/resolveAttachmentUrl.ts +++ b/lib/agents/content/resolveAttachmentUrl.ts @@ -28,7 +28,6 @@ export async function resolveAttachmentUrl( let data: Buffer | null = null; const token = process.env.SLACK_CONTENT_BOT_TOKEN; - // Extract Slack file ID from the attachment URL and use files.info API if (attachment.url && token) { const fileId = extractSlackFileId(attachment.url); if (fileId) { @@ -54,6 +53,5 @@ export async function resolveAttachmentUrl( const contentType = attachment.mimeType ?? (prefix === "audio" ? "audio/mpeg" : "image/png"); const blob = await put(blobPath, data, { access: "public", contentType }); - console.log(`[content-agent] Uploaded to Blob: ${blob.url}, size=${data.byteLength}`); return blob.url; } From f2235f5cd78a14d0c2a18b20089d4185c379bbff Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 17:46:31 -0500 Subject: [PATCH 15/15] refactor: move downloadSlackFile and extractSlackFileId to lib/slack/ Slack utilities belong with other Slack helpers, not in agents/content. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/resolveAttachmentUrl.ts | 4 ++-- lib/{agents/content => slack}/downloadSlackFile.ts | 0 lib/{agents/content => slack}/extractSlackFileId.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/{agents/content => slack}/downloadSlackFile.ts (100%) rename lib/{agents/content => slack}/extractSlackFileId.ts (100%) diff --git a/lib/agents/content/resolveAttachmentUrl.ts b/lib/agents/content/resolveAttachmentUrl.ts index 3912cc7f..6293342f 100644 --- a/lib/agents/content/resolveAttachmentUrl.ts +++ b/lib/agents/content/resolveAttachmentUrl.ts @@ -1,6 +1,6 @@ import { put } from "@vercel/blob"; -import { downloadSlackFile } from "./downloadSlackFile"; -import { extractSlackFileId } from "./extractSlackFileId"; +import { downloadSlackFile } from "@/lib/slack/downloadSlackFile"; +import { extractSlackFileId } from "@/lib/slack/extractSlackFileId"; interface Attachment { type: "image" | "file" | "video" | "audio"; diff --git a/lib/agents/content/downloadSlackFile.ts b/lib/slack/downloadSlackFile.ts similarity index 100% rename from lib/agents/content/downloadSlackFile.ts rename to lib/slack/downloadSlackFile.ts diff --git a/lib/agents/content/extractSlackFileId.ts b/lib/slack/extractSlackFileId.ts similarity index 100% rename from lib/agents/content/extractSlackFileId.ts rename to lib/slack/extractSlackFileId.ts