Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions lib/agents/content/__tests__/extractMessageAttachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
51 changes: 46 additions & 5 deletions lib/agents/content/__tests__/registerOnNewMention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("registerOnNewMention", () => {
vi.clearAllMocks();
vi.mocked(extractMessageAttachments).mockResolvedValue({
songUrl: null,
imageUrl: null,
imageUrls: [],
});
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down
11 changes: 6 additions & 5 deletions lib/agents/content/extractMessageAttachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface MessageWithAttachments {

export interface ExtractedAttachments {
songUrl: string | null;
imageUrl: string | null;
imageUrls: string[];
}

/**
Expand All @@ -29,7 +29,7 @@ export async function extractMessageAttachments(
): Promise<ExtractedAttachments> {
const result: ExtractedAttachments = {
songUrl: null,
imageUrl: null,
imageUrls: [],
};

const attachments = message.attachments;
Expand All @@ -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 {
Expand All @@ -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);
}
Expand Down
8 changes: 4 additions & 4 deletions lib/agents/content/handlers/registerOnNewMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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).`,
Expand All @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions lib/content/__tests__/contentTemplates.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 5 additions & 4 deletions lib/content/contentTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
11 changes: 11 additions & 0 deletions lib/content/isSupportedContentTemplate.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 2 additions & 4 deletions lib/content/validateCreateContentBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading