diff --git a/src/__tests__/messageHandler.test.ts b/src/__tests__/messageHandler.test.ts index 7182c85..5f9de06 100644 --- a/src/__tests__/messageHandler.test.ts +++ b/src/__tests__/messageHandler.test.ts @@ -33,6 +33,7 @@ function createMockMessage(overrides: Record = {}) { }, attachments: { first: vi.fn().mockReturnValue(undefined), + values: vi.fn().mockReturnValue([]), }, react: reactFn, reply: replyFn, @@ -279,4 +280,107 @@ describe('messageHandler - voice messages', () => { expect(executionService.runPrompt).not.toHaveBeenCalled(); }); }); + + describe('image attachment handling', () => { + it('should process image attachments with text prompt', async () => { + const msg = createMockMessage({ + content: 'Analyze this image', + attachments: { + first: vi.fn().mockReturnValue(undefined), + values: vi.fn().mockReturnValue([ + { + url: 'https://cdn.discordapp.com/attachments/123/image.png', + name: 'screenshot.png', + size: 102400, + contentType: 'image/png' + } + ]) + } + }); + + await handleMessageCreate(msg); + + expect(executionService.runPrompt).toHaveBeenCalledWith( + msg.channel, + 'thread-1', + 'Analyze this image\n\n[Image: screenshot.png (image/png, 100.0KB)]', + 'channel-1' + ); + expect(msg.react).toHaveBeenCalledWith('🖼️'); + }); + + it('should process image attachments without text prompt', async () => { + const msg = createMockMessage({ + content: '', + attachments: { + first: vi.fn().mockReturnValue(undefined), + values: vi.fn().mockReturnValue([ + { + url: 'https://cdn.discordapp.com/attachments/123/image.jpg', + name: 'photo.jpg', + size: 204800, + contentType: 'image/jpeg' + } + ]) + } + }); + + await handleMessageCreate(msg); + + expect(executionService.runPrompt).toHaveBeenCalledWith( + msg.channel, + 'thread-1', + '[Image: photo.jpg (image/jpeg, 200.0KB)]', + 'channel-1' + ); + expect(msg.react).toHaveBeenCalledWith('🖼️'); + }); + + it('should queue image attachments when thread is busy', async () => { + vi.mocked(queueManager.isBusy).mockReturnValue(true); + const msg = createMockMessage({ + content: 'Process these images', + attachments: { + first: vi.fn().mockReturnValue(undefined), + values: vi.fn().mockReturnValue([ + { + url: 'https://cdn.discordapp.com/attachments/123/img1.png', + name: 'img1.png', + size: 51200, + contentType: 'image/png' + }, + { + url: 'https://cdn.discordapp.com/attachments/123/img2.jpg', + name: 'img2.jpg', + size: 76800, + contentType: 'image/jpeg' + } + ]) + } + }); + + await handleMessageCreate(msg); + + expect(dataStore.addToQueue).toHaveBeenCalledWith('thread-1', { + prompt: 'Process these images', + userId: 'user-1', + timestamp: expect.any(Number), + imageAttachments: [ + { + url: 'https://cdn.discordapp.com/attachments/123/img1.png', + name: 'img1.png', + size: 51200, + contentType: 'image/png' + }, + { + url: 'https://cdn.discordapp.com/attachments/123/img2.jpg', + name: 'img2.jpg', + size: 76800, + contentType: 'image/jpeg' + } + ] + }); + expect(msg.react).toHaveBeenCalledWith('📥'); + }); + }); }); diff --git a/src/handlers/messageHandler.ts b/src/handlers/messageHandler.ts index 7954bf1..0de765b 100644 --- a/src/handlers/messageHandler.ts +++ b/src/handlers/messageHandler.ts @@ -25,6 +25,12 @@ async function safeRemoveReaction(message: Message, emoji: string): Promise { if (message.author.bot) return; if (message.system) return; @@ -46,10 +52,15 @@ export async function handleMessageCreate(message: Message): Promise { // Detect voice message before busy check so we can queue attachment metadata const isVoiceMessage = !prompt && isVoiceEnabled() && message.flags.has(MessageFlags.IsVoiceMessage); const voiceAttachment = isVoiceMessage ? message.attachments.first() : undefined; + + // Detect image attachments + const imageAttachments = message.attachments?.values + ? Array.from(message.attachments.values()).filter(isImageAttachment) + : []; - if (!prompt && !voiceAttachment) return; + if (!prompt && !voiceAttachment && imageAttachments.length === 0) return; - // Check busy BEFORE STT — queue voice attachment metadata if busy + // Check busy BEFORE STT — queue attachment metadata if busy if (isBusy(threadId)) { if (voiceAttachment) { dataStore.addToQueue(threadId, { @@ -59,6 +70,18 @@ export async function handleMessageCreate(message: Message): Promise { voiceAttachmentUrl: voiceAttachment.url, voiceAttachmentSize: voiceAttachment.size, }); + } else if (imageAttachments.length > 0) { + dataStore.addToQueue(threadId, { + prompt, + userId: message.author.id, + timestamp: Date.now(), + imageAttachments: imageAttachments.map(img => ({ + url: img.url, + name: img.name, + size: img.size, + contentType: img.contentType + })) + }); } else { dataStore.addToQueue(threadId, { prompt, @@ -92,5 +115,29 @@ export async function handleMessageCreate(message: Message): Promise { } } + // Process image attachments + if (imageAttachments.length > 0) { + await safeReact(message, '🖼️'); + try { + // Add image context to the prompt + const imageInfo = imageAttachments.map(img => + `[Image: ${img.name} (${img.contentType}, ${(img.size / 1024).toFixed(1)}KB)]` + ).join('\n'); + + if (prompt) { + prompt = `${prompt}\n\n${imageInfo}`; + } else { + prompt = imageInfo; + } + + await safeRemoveReaction(message, '🖼️'); + } catch (error) { + console.error('[Image Handler] Failed to process images:', error instanceof Error ? error.message : error); + await safeReact(message, '❌'); + await message.reply({ content: '❌ Failed to process image attachments.' }).catch(() => {}); + return; + } + } + await runPrompt(channel, threadId, prompt, parentChannelId); } diff --git a/src/services/queueManager.ts b/src/services/queueManager.ts index 4a844c8..8fc87e5 100644 --- a/src/services/queueManager.ts +++ b/src/services/queueManager.ts @@ -35,6 +35,19 @@ export async function processNextInQueue( } } + // Handle queued image attachments + if (next.imageAttachments && next.imageAttachments.length > 0) { + const imageInfo = next.imageAttachments.map(img => + `[Image: ${img.name} (${img.contentType}, ${(img.size / 1024).toFixed(1)}KB)]` + ).join('\n'); + + if (prompt) { + prompt = `${prompt}\n\n${imageInfo}`; + } else { + prompt = imageInfo; + } + } + if (!prompt) return; // Visual indication that we are starting the next one diff --git a/src/types/index.ts b/src/types/index.ts index f6ae651..aeeefce 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,12 +23,20 @@ export interface DataStore { queueSettings?: Record; } +export interface ImageAttachment { + url: string; + name: string; + size: number; + contentType: string | null; +} + export interface QueuedMessage { prompt: string; userId: string; timestamp: number; voiceAttachmentUrl?: string; voiceAttachmentSize?: number; + imageAttachments?: ImageAttachment[]; } export interface QueueSettings {