From 1b804de7d1b957368e92f6503e2e22853ea90d93 Mon Sep 17 00:00:00 2001 From: bread Date: Sun, 24 May 2026 12:21:59 +1000 Subject: [PATCH 1/2] feat: Add image upload support to Discord connector - Detect image attachments (JPEG, PNG, GIF, WebP, SVG) in Discord messages - Add image context to prompts with filename, format, and size info - Support multiple images per message - Properly queue images when bot is busy - Add comprehensive test coverage for image handling - Follow existing voice message pattern for consistency Fixes: Image attachments were previously ignored by the bot --- src/__tests__/messageHandler.test.ts | 104 +++++++++++++++++++++++++++ src/handlers/messageHandler.ts | 51 ++++++++++++- src/services/queueManager.ts | 13 ++++ src/types/index.ts | 8 +++ 4 files changed, 174 insertions(+), 2 deletions(-) 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..868ba7c 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; +} + export interface QueuedMessage { prompt: string; userId: string; timestamp: number; voiceAttachmentUrl?: string; voiceAttachmentSize?: number; + imageAttachments?: ImageAttachment[]; } export interface QueueSettings { From 0d00284c106501dcf9e73e81696cf95d0aa5f313 Mon Sep 17 00:00:00 2001 From: bread Date: Sun, 24 May 2026 12:29:59 +1000 Subject: [PATCH 2/2] fix: TypeScript build error for ImageAttachment contentType - Update ImageAttachment interface to allow null contentType - Discord's attachment contentType can be null, so interface must match - Build now passes successfully --- src/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/index.ts b/src/types/index.ts index 868ba7c..aeeefce 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,7 +27,7 @@ export interface ImageAttachment { url: string; name: string; size: number; - contentType: string; + contentType: string | null; } export interface QueuedMessage {