diff --git a/lib/agents/content/__tests__/extractMessageAttachments.test.ts b/lib/agents/content/__tests__/extractMessageAttachments.test.ts index 3db6fc38..4dee2308 100644 --- a/lib/agents/content/__tests__/extractMessageAttachments.test.ts +++ b/lib/agents/content/__tests__/extractMessageAttachments.test.ts @@ -12,16 +12,16 @@ describe("extractMessageAttachments", () => { vi.clearAllMocks(); }); - it("returns null values when message has no attachments", async () => { + it("returns null/empty values when message has no attachments", async () => { const message = { text: "hello", attachments: [] }; const result = await extractMessageAttachments(message as never); - expect(result).toEqual({ songUrl: null, imageUrl: null }); + expect(result).toEqual({ songUrl: null, imageUrls: [] }); }); - it("returns null values when attachments is undefined", async () => { + it("returns null/empty values when attachments is undefined", async () => { const message = { text: "hello" }; const result = await extractMessageAttachments(message as never); - expect(result).toEqual({ songUrl: null, imageUrl: null }); + expect(result).toEqual({ songUrl: null, imageUrls: [] }); }); it("uploads audio with correct contentType", async () => { @@ -48,7 +48,7 @@ describe("extractMessageAttachments", () => { contentType: "audio/mpeg", }); expect(result.songUrl).toBe("https://blob.vercel-storage.com/content-attachments/my-song.mp3"); - expect(result.imageUrl).toBeNull(); + expect(result.imageUrls).toEqual([]); }); it("extracts and uploads an image attachment", async () => { @@ -69,7 +69,9 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); - expect(result.imageUrl).toBe("https://blob.vercel-storage.com/content-attachments/face.png"); + expect(result.imageUrls).toContain( + "https://blob.vercel-storage.com/content-attachments/face.png", + ); expect(result.songUrl).toBeNull(); }); @@ -101,7 +103,39 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); expect(result.songUrl).toBe("https://blob.vercel-storage.com/song.mp3"); - expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + expect(result.imageUrls).toContain("https://blob.vercel-storage.com/photo.jpg"); + }); + + it("extracts all image attachments when multiple images are attached", async () => { + const img1 = Buffer.from("img1"); + const img2 = Buffer.from("img2"); + const img3 = Buffer.from("img3"); + const message = { + text: "hello", + attachments: [ + { type: "image", name: "face.png", fetchData: vi.fn().mockResolvedValue(img1) }, + { type: "image", name: "cover1.png", fetchData: vi.fn().mockResolvedValue(img2) }, + { type: "image", name: "cover2.png", fetchData: vi.fn().mockResolvedValue(img3) }, + ], + }; + vi.mocked(put).mockResolvedValueOnce({ + url: "https://blob.vercel-storage.com/face.png", + } as never); + vi.mocked(put).mockResolvedValueOnce({ + url: "https://blob.vercel-storage.com/cover1.png", + } as never); + vi.mocked(put).mockResolvedValueOnce({ + url: "https://blob.vercel-storage.com/cover2.png", + } as never); + + const result = await extractMessageAttachments(message as never); + + expect(result.imageUrls).toHaveLength(3); + expect(result.imageUrls).toEqual([ + "https://blob.vercel-storage.com/face.png", + "https://blob.vercel-storage.com/cover1.png", + "https://blob.vercel-storage.com/cover2.png", + ]); }); it("uses attachment data buffer if fetchData is not available", async () => { @@ -168,7 +202,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); - expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + expect(result.imageUrls).toContain("https://blob.vercel-storage.com/photo.jpg"); }); it("detects audio from file type with audio mimeType (Slack uploads)", async () => { @@ -205,7 +239,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); expect(put).not.toHaveBeenCalled(); - expect(result).toEqual({ songUrl: null, imageUrl: null }); + expect(result).toEqual({ songUrl: null, imageUrls: [] }); }); it("returns null when attachment has no data and no fetchData", async () => { @@ -252,7 +286,7 @@ describe("extractMessageAttachments", () => { const result = await extractMessageAttachments(message as never); expect(result.songUrl).toBeNull(); - expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg"); + expect(result.imageUrls).toEqual(["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 aedd578d..ec67085e 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -83,7 +83,7 @@ describe("registerOnNewMention", () => { vi.clearAllMocks(); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: null, - imageUrl: null, + imageUrls: [], }); }); @@ -299,7 +299,7 @@ describe("registerOnNewMention", () => { } as never); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: "https://blob.vercel-storage.com/song.mp3", - imageUrl: null, + imageUrls: [], }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); @@ -329,7 +329,7 @@ describe("registerOnNewMention", () => { } as never); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: null, - imageUrl: "https://blob.vercel-storage.com/face.png", + imageUrls: ["https://blob.vercel-storage.com/face.png"], }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); @@ -345,6 +345,47 @@ describe("registerOnNewMention", () => { ); }); + it("passes all image URLs when multiple images are attached", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-release-editorial", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(extractMessageAttachments).mockResolvedValue({ + songUrl: null, + imageUrls: [ + "https://blob.vercel-storage.com/face.png", + "https://blob.vercel-storage.com/cover1.png", + "https://blob.vercel-storage.com/cover2.png", + ], + }); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make an editorial video"); + await bot.getHandler()!(thread, message); + + expect(triggerCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + images: [ + "https://blob.vercel-storage.com/face.png", + "https://blob.vercel-storage.com/cover1.png", + "https://blob.vercel-storage.com/cover2.png", + ], + }), + ); + }); + it("omits images from payload when no media is attached", async () => { const bot = createMockBot(); registerOnNewMention(bot as never); @@ -388,7 +429,7 @@ describe("registerOnNewMention", () => { } as never); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: "https://blob.vercel-storage.com/song.mp3", - imageUrl: "https://blob.vercel-storage.com/face.png", + imageUrls: ["https://blob.vercel-storage.com/face.png"], }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); @@ -399,7 +440,7 @@ describe("registerOnNewMention", () => { const ackMessage = thread.post.mock.calls[0][0] as string; expect(ackMessage).toContain("Audio: attached file"); - expect(ackMessage).toContain("Image: attached file"); + expect(ackMessage).toContain("Images: 1 attached"); }); it("includes song names in acknowledgment message", async () => { diff --git a/lib/agents/content/extractMessageAttachments.ts b/lib/agents/content/extractMessageAttachments.ts index de40806f..7aceede9 100644 --- a/lib/agents/content/extractMessageAttachments.ts +++ b/lib/agents/content/extractMessageAttachments.ts @@ -15,7 +15,7 @@ interface MessageWithAttachments { export interface ExtractedAttachments { songUrl: string | null; - imageUrl: string | null; + imageUrls: string[]; } /** @@ -29,7 +29,7 @@ export async function extractMessageAttachments( ): Promise { const result: ExtractedAttachments = { songUrl: null, - imageUrl: null, + imageUrls: [], }; const attachments = message.attachments; @@ -41,7 +41,7 @@ export async function extractMessageAttachments( const isImage = (a: Attachment) => a.type === "image" || a.mimeType?.startsWith("image/"); const audioAttachment = attachments.find(isAudio); - const imageAttachment = attachments.find(isImage); + const imageAttachments = attachments.filter(isImage); if (audioAttachment) { try { @@ -51,9 +51,10 @@ export async function extractMessageAttachments( } } - if (imageAttachment) { + for (const imageAttachment of imageAttachments) { try { - result.imageUrl = await resolveAttachmentUrl(imageAttachment, "image"); + const url = await resolveAttachmentUrl(imageAttachment, "image"); + if (url) result.imageUrls.push(url); } catch (error) { console.error("[content-agent] Failed to resolve image attachment:", error); } diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 09236116..0ea11d71 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 { songUrl, imageUrl } = await extractMessageAttachments(message); + const { songUrl, imageUrls } = await extractMessageAttachments(message); // Resolve artist slug const artistSlug = await resolveArtistSlug(artistAccountId); @@ -72,8 +72,8 @@ export function registerOnNewMention(bot: ContentAgentBot) { if (songUrl) { details.push("- Audio: attached file"); } - if (imageUrl) { - details.push("- Image: attached file (face guide)"); + if (imageUrls.length > 0) { + details.push(`- Images: ${imageUrls.length} attached`); } await thread.post( `Generating content...\n${details.join("\n")}\n\nI'll reply here when ready (~5-10 min).`, @@ -92,7 +92,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { upscale, githubRepo, ...(allSongs.length > 0 && { songs: allSongs }), - ...(imageUrl && { images: [imageUrl] }), + ...(imageUrls.length > 0 && { images: imageUrls }), }; const results = await Promise.allSettled( diff --git a/lib/content/__tests__/contentTemplates.test.ts b/lib/content/__tests__/contentTemplates.test.ts new file mode 100644 index 00000000..c735a0a2 --- /dev/null +++ b/lib/content/__tests__/contentTemplates.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { CONTENT_TEMPLATES } from "@/lib/content/contentTemplates"; +import { isSupportedContentTemplate } from "@/lib/content/isSupportedContentTemplate"; + +describe("contentTemplates", () => { + it("includes artist-release-editorial template", () => { + const template = CONTENT_TEMPLATES.find(t => t.name === "artist-release-editorial"); + expect(template).toBeDefined(); + expect(template!.description).toBeTruthy(); + expect(template!.defaultLipsync).toBe(false); + }); + + it("validates artist-release-editorial as supported", () => { + expect(isSupportedContentTemplate("artist-release-editorial")).toBe(true); + }); +}); diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts index 179c7453..e7ecac02 100644 --- a/lib/content/contentTemplates.ts +++ b/lib/content/contentTemplates.ts @@ -25,11 +25,12 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [ description: "Album art on vinyl in a NYC record store", defaultLipsync: false, }, + { + name: "artist-release-editorial", + description: "Editorial promo featuring artist press photo, playlist covers, and DSP branding", + defaultLipsync: false, + }, ]; /** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ export const DEFAULT_CONTENT_TEMPLATE = CONTENT_TEMPLATES[0].name; - -export function isSupportedContentTemplate(template: string): boolean { - return CONTENT_TEMPLATES.some(item => item.name === template); -} diff --git a/lib/content/isSupportedContentTemplate.ts b/lib/content/isSupportedContentTemplate.ts new file mode 100644 index 00000000..68d34f8c --- /dev/null +++ b/lib/content/isSupportedContentTemplate.ts @@ -0,0 +1,11 @@ +import { CONTENT_TEMPLATES } from "./contentTemplates"; + +/** + * Checks if a template name is in the supported templates list. + * + * @param template - The template name to validate + * @returns Whether the template is supported + */ +export function isSupportedContentTemplate(template: string): boolean { + return CONTENT_TEMPLATES.some(item => item.name === template); +} diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 80049ce1..f6dccad0 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -4,10 +4,8 @@ import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { - DEFAULT_CONTENT_TEMPLATE, - isSupportedContentTemplate, -} from "@/lib/content/contentTemplates"; +import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; +import { isSupportedContentTemplate } from "@/lib/content/isSupportedContentTemplate"; import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { songsSchema } from "@/lib/content/songsSchema";