Skip to content

Commit fe1e264

Browse files
recoup-coding-agentCTO AgentPaperclip-Paperclipsweetmantechclaude
authored
feat: add artist-release-editorial content template (#408)
* feat: add artist-release-editorial content template Register the new artist-release-editorial template for editorial-style release promos featuring artist press photos, playlist covers, and DSP branding. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: extract all image attachments from Slack messages extractMessageAttachments now returns imageUrls (string[]) instead of imageUrl (string | null), collecting all image attachments rather than only the first. registerOnNewMention passes the full array to the content pipeline so multiple playlist covers / logos are forwarded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: merge test branch and fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Revert "chore: merge test branch and fix formatting" This reverts commit 023643b. * chore: fix formatting in extractMessageAttachments test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract isSupportedContentTemplate to own file (SRP) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CTO Agent <cto@recoup.ai> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a3eec5d commit fe1e264

File tree

8 files changed

+134
-32
lines changed

8 files changed

+134
-32
lines changed

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

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ describe("extractMessageAttachments", () => {
1212
vi.clearAllMocks();
1313
});
1414

15-
it("returns null values when message has no attachments", async () => {
15+
it("returns null/empty values when message has no attachments", async () => {
1616
const message = { text: "hello", attachments: [] };
1717
const result = await extractMessageAttachments(message as never);
18-
expect(result).toEqual({ songUrl: null, imageUrl: null });
18+
expect(result).toEqual({ songUrl: null, imageUrls: [] });
1919
});
2020

21-
it("returns null values when attachments is undefined", async () => {
21+
it("returns null/empty values when attachments is undefined", async () => {
2222
const message = { text: "hello" };
2323
const result = await extractMessageAttachments(message as never);
24-
expect(result).toEqual({ songUrl: null, imageUrl: null });
24+
expect(result).toEqual({ songUrl: null, imageUrls: [] });
2525
});
2626

2727
it("uploads audio with correct contentType", async () => {
@@ -48,7 +48,7 @@ describe("extractMessageAttachments", () => {
4848
contentType: "audio/mpeg",
4949
});
5050
expect(result.songUrl).toBe("https://blob.vercel-storage.com/content-attachments/my-song.mp3");
51-
expect(result.imageUrl).toBeNull();
51+
expect(result.imageUrls).toEqual([]);
5252
});
5353

5454
it("extracts and uploads an image attachment", async () => {
@@ -69,7 +69,9 @@ describe("extractMessageAttachments", () => {
6969

7070
const result = await extractMessageAttachments(message as never);
7171

72-
expect(result.imageUrl).toBe("https://blob.vercel-storage.com/content-attachments/face.png");
72+
expect(result.imageUrls).toContain(
73+
"https://blob.vercel-storage.com/content-attachments/face.png",
74+
);
7375
expect(result.songUrl).toBeNull();
7476
});
7577

@@ -101,7 +103,39 @@ describe("extractMessageAttachments", () => {
101103
const result = await extractMessageAttachments(message as never);
102104

103105
expect(result.songUrl).toBe("https://blob.vercel-storage.com/song.mp3");
104-
expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg");
106+
expect(result.imageUrls).toContain("https://blob.vercel-storage.com/photo.jpg");
107+
});
108+
109+
it("extracts all image attachments when multiple images are attached", async () => {
110+
const img1 = Buffer.from("img1");
111+
const img2 = Buffer.from("img2");
112+
const img3 = Buffer.from("img3");
113+
const message = {
114+
text: "hello",
115+
attachments: [
116+
{ type: "image", name: "face.png", fetchData: vi.fn().mockResolvedValue(img1) },
117+
{ type: "image", name: "cover1.png", fetchData: vi.fn().mockResolvedValue(img2) },
118+
{ type: "image", name: "cover2.png", fetchData: vi.fn().mockResolvedValue(img3) },
119+
],
120+
};
121+
vi.mocked(put).mockResolvedValueOnce({
122+
url: "https://blob.vercel-storage.com/face.png",
123+
} as never);
124+
vi.mocked(put).mockResolvedValueOnce({
125+
url: "https://blob.vercel-storage.com/cover1.png",
126+
} as never);
127+
vi.mocked(put).mockResolvedValueOnce({
128+
url: "https://blob.vercel-storage.com/cover2.png",
129+
} as never);
130+
131+
const result = await extractMessageAttachments(message as never);
132+
133+
expect(result.imageUrls).toHaveLength(3);
134+
expect(result.imageUrls).toEqual([
135+
"https://blob.vercel-storage.com/face.png",
136+
"https://blob.vercel-storage.com/cover1.png",
137+
"https://blob.vercel-storage.com/cover2.png",
138+
]);
105139
});
106140

107141
it("uses attachment data buffer if fetchData is not available", async () => {
@@ -168,7 +202,7 @@ describe("extractMessageAttachments", () => {
168202

169203
const result = await extractMessageAttachments(message as never);
170204

171-
expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg");
205+
expect(result.imageUrls).toContain("https://blob.vercel-storage.com/photo.jpg");
172206
});
173207

174208
it("detects audio from file type with audio mimeType (Slack uploads)", async () => {
@@ -205,7 +239,7 @@ describe("extractMessageAttachments", () => {
205239
const result = await extractMessageAttachments(message as never);
206240

207241
expect(put).not.toHaveBeenCalled();
208-
expect(result).toEqual({ songUrl: null, imageUrl: null });
242+
expect(result).toEqual({ songUrl: null, imageUrls: [] });
209243
});
210244

211245
it("returns null when attachment has no data and no fetchData", async () => {
@@ -252,7 +286,7 @@ describe("extractMessageAttachments", () => {
252286
const result = await extractMessageAttachments(message as never);
253287

254288
expect(result.songUrl).toBeNull();
255-
expect(result.imageUrl).toBe("https://blob.vercel-storage.com/photo.jpg");
289+
expect(result.imageUrls).toEqual(["https://blob.vercel-storage.com/photo.jpg"]);
256290
});
257291

258292
it("falls back to generic name when attachment name is missing", async () => {

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe("registerOnNewMention", () => {
8383
vi.clearAllMocks();
8484
vi.mocked(extractMessageAttachments).mockResolvedValue({
8585
songUrl: null,
86-
imageUrl: null,
86+
imageUrls: [],
8787
});
8888
});
8989

@@ -299,7 +299,7 @@ describe("registerOnNewMention", () => {
299299
} as never);
300300
vi.mocked(extractMessageAttachments).mockResolvedValue({
301301
songUrl: "https://blob.vercel-storage.com/song.mp3",
302-
imageUrl: null,
302+
imageUrls: [],
303303
});
304304
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" });
305305
vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never);
@@ -329,7 +329,7 @@ describe("registerOnNewMention", () => {
329329
} as never);
330330
vi.mocked(extractMessageAttachments).mockResolvedValue({
331331
songUrl: null,
332-
imageUrl: "https://blob.vercel-storage.com/face.png",
332+
imageUrls: ["https://blob.vercel-storage.com/face.png"],
333333
});
334334
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" });
335335
vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never);
@@ -345,6 +345,47 @@ describe("registerOnNewMention", () => {
345345
);
346346
});
347347

348+
it("passes all image URLs when multiple images are attached", async () => {
349+
const bot = createMockBot();
350+
registerOnNewMention(bot as never);
351+
352+
vi.mocked(parseContentPrompt).mockResolvedValue({
353+
lipsync: false,
354+
batch: 1,
355+
captionLength: "short",
356+
upscale: false,
357+
template: "artist-release-editorial",
358+
});
359+
vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist");
360+
vi.mocked(getArtistContentReadiness).mockResolvedValue({
361+
githubRepo: "https://github.com/test/repo",
362+
} as never);
363+
vi.mocked(extractMessageAttachments).mockResolvedValue({
364+
songUrl: null,
365+
imageUrls: [
366+
"https://blob.vercel-storage.com/face.png",
367+
"https://blob.vercel-storage.com/cover1.png",
368+
"https://blob.vercel-storage.com/cover2.png",
369+
],
370+
});
371+
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" });
372+
vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never);
373+
374+
const thread = createMockThread();
375+
const message = createMockMessage("make an editorial video");
376+
await bot.getHandler()!(thread, message);
377+
378+
expect(triggerCreateContent).toHaveBeenCalledWith(
379+
expect.objectContaining({
380+
images: [
381+
"https://blob.vercel-storage.com/face.png",
382+
"https://blob.vercel-storage.com/cover1.png",
383+
"https://blob.vercel-storage.com/cover2.png",
384+
],
385+
}),
386+
);
387+
});
388+
348389
it("omits images from payload when no media is attached", async () => {
349390
const bot = createMockBot();
350391
registerOnNewMention(bot as never);
@@ -388,7 +429,7 @@ describe("registerOnNewMention", () => {
388429
} as never);
389430
vi.mocked(extractMessageAttachments).mockResolvedValue({
390431
songUrl: "https://blob.vercel-storage.com/song.mp3",
391-
imageUrl: "https://blob.vercel-storage.com/face.png",
432+
imageUrls: ["https://blob.vercel-storage.com/face.png"],
392433
});
393434
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" });
394435
vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never);
@@ -399,7 +440,7 @@ describe("registerOnNewMention", () => {
399440

400441
const ackMessage = thread.post.mock.calls[0][0] as string;
401442
expect(ackMessage).toContain("Audio: attached file");
402-
expect(ackMessage).toContain("Image: attached file");
443+
expect(ackMessage).toContain("Images: 1 attached");
403444
});
404445

405446
it("includes song names in acknowledgment message", async () => {

lib/agents/content/extractMessageAttachments.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface MessageWithAttachments {
1515

1616
export interface ExtractedAttachments {
1717
songUrl: string | null;
18-
imageUrl: string | null;
18+
imageUrls: string[];
1919
}
2020

2121
/**
@@ -29,7 +29,7 @@ export async function extractMessageAttachments(
2929
): Promise<ExtractedAttachments> {
3030
const result: ExtractedAttachments = {
3131
songUrl: null,
32-
imageUrl: null,
32+
imageUrls: [],
3333
};
3434

3535
const attachments = message.attachments;
@@ -41,7 +41,7 @@ export async function extractMessageAttachments(
4141
const isImage = (a: Attachment) => a.type === "image" || a.mimeType?.startsWith("image/");
4242

4343
const audioAttachment = attachments.find(isAudio);
44-
const imageAttachment = attachments.find(isImage);
44+
const imageAttachments = attachments.filter(isImage);
4545

4646
if (audioAttachment) {
4747
try {
@@ -51,9 +51,10 @@ export async function extractMessageAttachments(
5151
}
5252
}
5353

54-
if (imageAttachment) {
54+
for (const imageAttachment of imageAttachments) {
5555
try {
56-
result.imageUrl = await resolveAttachmentUrl(imageAttachment, "image");
56+
const url = await resolveAttachmentUrl(imageAttachment, "image");
57+
if (url) result.imageUrls.push(url);
5758
} catch (error) {
5859
console.error("[content-agent] Failed to resolve image attachment:", error);
5960
}

lib/agents/content/handlers/registerOnNewMention.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function registerOnNewMention(bot: ContentAgentBot) {
2727
);
2828

2929
// Extract audio/image attachments from the Slack message
30-
const { songUrl, imageUrl } = await extractMessageAttachments(message);
30+
const { songUrl, imageUrls } = await extractMessageAttachments(message);
3131

3232
// Resolve artist slug
3333
const artistSlug = await resolveArtistSlug(artistAccountId);
@@ -72,8 +72,8 @@ export function registerOnNewMention(bot: ContentAgentBot) {
7272
if (songUrl) {
7373
details.push("- Audio: attached file");
7474
}
75-
if (imageUrl) {
76-
details.push("- Image: attached file (face guide)");
75+
if (imageUrls.length > 0) {
76+
details.push(`- Images: ${imageUrls.length} attached`);
7777
}
7878
await thread.post(
7979
`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) {
9292
upscale,
9393
githubRepo,
9494
...(allSongs.length > 0 && { songs: allSongs }),
95-
...(imageUrl && { images: [imageUrl] }),
95+
...(imageUrls.length > 0 && { images: imageUrls }),
9696
};
9797

9898
const results = await Promise.allSettled(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { CONTENT_TEMPLATES } from "@/lib/content/contentTemplates";
3+
import { isSupportedContentTemplate } from "@/lib/content/isSupportedContentTemplate";
4+
5+
describe("contentTemplates", () => {
6+
it("includes artist-release-editorial template", () => {
7+
const template = CONTENT_TEMPLATES.find(t => t.name === "artist-release-editorial");
8+
expect(template).toBeDefined();
9+
expect(template!.description).toBeTruthy();
10+
expect(template!.defaultLipsync).toBe(false);
11+
});
12+
13+
it("validates artist-release-editorial as supported", () => {
14+
expect(isSupportedContentTemplate("artist-release-editorial")).toBe(true);
15+
});
16+
});

lib/content/contentTemplates.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [
2525
description: "Album art on vinyl in a NYC record store",
2626
defaultLipsync: false,
2727
},
28+
{
29+
name: "artist-release-editorial",
30+
description: "Editorial promo featuring artist press photo, playlist covers, and DSP branding",
31+
defaultLipsync: false,
32+
},
2833
];
2934

3035
/** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */
3136
export const DEFAULT_CONTENT_TEMPLATE = CONTENT_TEMPLATES[0].name;
32-
33-
export function isSupportedContentTemplate(template: string): boolean {
34-
return CONTENT_TEMPLATES.some(item => item.name === template);
35-
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { CONTENT_TEMPLATES } from "./contentTemplates";
2+
3+
/**
4+
* Checks if a template name is in the supported templates list.
5+
*
6+
* @param template - The template name to validate
7+
* @returns Whether the template is supported
8+
*/
9+
export function isSupportedContentTemplate(template: string): boolean {
10+
return CONTENT_TEMPLATES.some(item => item.name === template);
11+
}

lib/content/validateCreateContentBody.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ import { z } from "zod";
44
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
55
import { safeParseJson } from "@/lib/networking/safeParseJson";
66
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
7-
import {
8-
DEFAULT_CONTENT_TEMPLATE,
9-
isSupportedContentTemplate,
10-
} from "@/lib/content/contentTemplates";
7+
import { DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates";
8+
import { isSupportedContentTemplate } from "@/lib/content/isSupportedContentTemplate";
119
import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug";
1210
import { songsSchema } from "@/lib/content/songsSchema";
1311

0 commit comments

Comments
 (0)