Skip to content
Draft
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
104 changes: 104 additions & 0 deletions src/__tests__/messageHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function createMockMessage(overrides: Record<string, unknown> = {}) {
},
attachments: {
first: vi.fn().mockReturnValue(undefined),
values: vi.fn().mockReturnValue([]),
},
react: reactFn,
reply: replyFn,
Expand Down Expand Up @@ -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('📥');
});
});
});
51 changes: 49 additions & 2 deletions src/handlers/messageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ async function safeRemoveReaction(message: Message, emoji: string): Promise<void
}
}

function isImageAttachment(attachment: any): boolean {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
return imageTypes.includes(attachment.contentType) ||
attachment.name?.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i);
}

export async function handleMessageCreate(message: Message): Promise<void> {
if (message.author.bot) return;
if (message.system) return;
Expand All @@ -46,10 +52,15 @@ export async function handleMessageCreate(message: Message): Promise<void> {
// 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, {
Expand All @@ -59,6 +70,18 @@ export async function handleMessageCreate(message: Message): Promise<void> {
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,
Expand Down Expand Up @@ -92,5 +115,29 @@ export async function handleMessageCreate(message: Message): Promise<void> {
}
}

// Process image attachments
if (imageAttachments.length > 0) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only add image metadata like name, contentType, not the image itself, will be nice if we can pass image content in

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point - i'll keep updating this

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);
}
13 changes: 13 additions & 0 deletions src/services/queueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,20 @@ export interface DataStore {
queueSettings?: Record<string, QueueSettings>;
}

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 {
Expand Down