From 0b2ace66757a93bea5a5b76d4fa2026a1020c069 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 23 May 2026 15:50:58 +0900 Subject: [PATCH] fix: expose discord file attachment paths --- src/channels/discord-attachments.ts | 125 ++++++++++++++++++++++++++++ src/channels/discord.test.ts | 102 +++++++++++++++++++++-- src/channels/discord.ts | 82 ++++-------------- 3 files changed, 234 insertions(+), 75 deletions(-) create mode 100644 src/channels/discord-attachments.ts diff --git a/src/channels/discord-attachments.ts b/src/channels/discord-attachments.ts new file mode 100644 index 0000000..c6d798d --- /dev/null +++ b/src/channels/discord-attachments.ts @@ -0,0 +1,125 @@ +import fs from 'fs'; +import path from 'path'; + +import { Attachment } from 'discord.js'; + +import { DATA_DIR } from '../config.js'; +import { logger } from '../logger.js'; + +const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); +const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024; +const TEXT_ATTACHMENT_NAME_PATTERN = + /\.(txt|md|json|csv|log|xml|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|py|js|ts|jsx|tsx|html|css|sql|rs|go|java|c|cpp|h|hpp|rb|php|swift|kt|scala|r|lua|pl|ex|exs|hs|ml|clj|dart|v|zig|nim|ps1|bat|cmd|mjs|cjs)$/i; + +async function downloadAttachment( + att: Attachment, + defaultExt = '.bin', +): Promise { + const res = await fetch(att.url); + if (!res.ok) throw new Error(`Download failed: ${res.status}`); + const buffer = Buffer.from(await res.arrayBuffer()); + + fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); + const ext = path.extname(att.name || `file${defaultExt}`) || defaultExt; + const attachmentId = String(att.id || 'attachment').replace( + /[^a-zA-Z0-9_-]/g, + '_', + ); + const filename = `${Date.now()}-${attachmentId}${ext}`; + const filePath = path.join(ATTACHMENTS_DIR, filename); + fs.writeFileSync(filePath, buffer); + logger.info({ file: filename, size: buffer.length }, 'Attachment downloaded'); + return filePath; +} + +function attachmentDefaultExtension(contentType: string): string { + if (contentType.startsWith('image/')) return '.png'; + if (contentType.startsWith('audio/')) return '.wav'; + if (contentType.startsWith('video/')) return '.mp4'; + if (contentType.startsWith('text/')) return '.txt'; + if (contentType === 'application/pdf') return '.pdf'; + if ( + contentType === 'application/zip' || + contentType === 'application/x-zip-compressed' + ) { + return '.zip'; + } + return '.bin'; +} + +function attachmentLabel( + contentType: string, +): 'Audio' | 'File' | 'Image' | 'Video' { + if (contentType.startsWith('image/')) return 'Image'; + if (contentType.startsWith('audio/')) return 'Audio'; + if (contentType.startsWith('video/')) return 'Video'; + return 'File'; +} + +function isTextAttachment(att: Attachment, contentType: string): boolean { + return ( + contentType.startsWith('text/') || + TEXT_ATTACHMENT_NAME_PATTERN.test(att.name || '') + ); +} + +function formatAttachmentReference( + label: 'Audio' | 'File' | 'Image' | 'Video', + att: Attachment, + filePath: string, +): string { + return `[${label}: ${att.name || 'file'} → ${filePath}]`; +} + +function formatBytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function getDeclaredAttachmentSize(att: Attachment): number | null { + return typeof att.size === 'number' && Number.isFinite(att.size) + ? att.size + : null; +} + +function formatTooLargeAttachment( + label: 'Audio' | 'File' | 'Image' | 'Video', + att: Attachment, + size: number, +): string { + return `[${label}: ${att.name || 'file'} (too large to download: ${formatBytes(size)} > ${formatBytes(MAX_ATTACHMENT_BYTES)})]`; +} + +export async function describeDownloadedAttachment( + att: Attachment, + contentType: string, +): Promise { + const label = attachmentLabel(contentType); + const declaredSize = getDeclaredAttachmentSize(att); + if (declaredSize != null && declaredSize > MAX_ATTACHMENT_BYTES) { + logger.warn( + { + file: att.name, + size: declaredSize, + maxSize: MAX_ATTACHMENT_BYTES, + }, + 'Attachment skipped because it exceeds the download size cap', + ); + return formatTooLargeAttachment(label, att, declaredSize); + } + + const filePath = await downloadAttachment( + att, + attachmentDefaultExtension(contentType), + ); + const reference = formatAttachmentReference(label, att, filePath); + if (!isTextAttachment(att, contentType)) return reference; + + let text = fs.readFileSync(filePath, 'utf8'); + const MAX_TEXT_LENGTH = 32_000; + if (text.length > MAX_TEXT_LENGTH) { + text = + text.slice(0, MAX_TEXT_LENGTH) + + `\n...(truncated, ${text.length} chars total)`; + } + return `${reference}\n${text}`; +} diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 0912fe8..78dc8df 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -604,7 +604,10 @@ describe('attachments', () => { 'fetch', vi.fn().mockResolvedValue({ ok: true, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode('Hello from text file').buffer, + ), text: () => Promise.resolve('Hello from text file'), }), ); @@ -645,13 +648,21 @@ describe('attachments', () => { ); }); - it('stores video attachment with placeholder', async () => { + it('stores video attachment with local path', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ - ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }], + [ + 'att1', + { + id: 'att1', + name: 'clip.mp4', + contentType: 'video/mp4', + url: 'https://cdn.example.com/clip.mp4', + }, + ], ]); const msg = createMessage({ content: '', @@ -663,18 +674,26 @@ describe('attachments', () => { expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ - content: '[Video: clip.mp4]', + content: expect.stringMatching(/^\[Video: clip\.mp4 → .+\.mp4\]$/), }), ); }); - it('stores file attachment with placeholder', async () => { + it('stores file attachment with local path', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ - ['att1', { name: 'report.pdf', contentType: 'application/pdf' }], + [ + 'att1', + { + id: 'att1', + name: 'report.pdf', + contentType: 'application/pdf', + url: 'https://cdn.example.com/report.pdf', + }, + ], ]); const msg = createMessage({ content: '', @@ -686,7 +705,74 @@ describe('attachments', () => { expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ - content: '[File: report.pdf]', + content: expect.stringMatching(/^\[File: report\.pdf → .+\.pdf\]$/), + }), + ); + }); + + it('stores zip attachment with local path', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + [ + 'att1', + { + id: 'att1', + name: 'display_latency_atopile_rev09.zip', + contentType: 'application/zip', + url: 'https://cdn.example.com/display_latency_atopile_rev09.zip', + }, + ], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: expect.stringMatching( + /^\[File: display_latency_atopile_rev09\.zip → .+\.zip\]$/, + ), + }), + ); + }); + + it('skips oversized file attachments before download', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + [ + 'att1', + { + id: 'att1', + name: 'huge.zip', + contentType: 'application/zip', + size: 51 * 1024 * 1024, + url: 'https://cdn.example.com/huge.zip', + }, + ], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: + '[File: huge.zip (too large to download: 51.0 MiB > 50.0 MiB)]', }), ); }); @@ -758,7 +844,7 @@ describe('attachments', () => { 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching( - /^\[Image: .+\.png\]\n\[File: b\.txt\]\nHello from text file$/, + /^\[Image: .+\.png\]\n\[File: b\.txt → .+\.txt\]\nHello from text file$/, ), }), ); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 8cc2c9e..05b8fbf 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -11,7 +11,7 @@ import { TextChannel, } from 'discord.js'; -import { CACHE_DIR, DATA_DIR } from '../config.js'; +import { CACHE_DIR } from '../config.js'; import { getEnv } from '../env.js'; import { logger } from '../logger.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; @@ -27,10 +27,10 @@ import { deleteOwnDashboardDuplicateOnCreate, isDashboardTrackedSend, } from './discord-dashboard-create-cleanup.js'; +import { describeDownloadedAttachment } from './discord-attachments.js'; import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; import { prepareDiscordOutbound } from './discord-outbound.js'; -const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); const DISCORD_OWNER_CHANNEL = 'discord'; const DISCORD_REVIEWER_CHANNEL = 'discord-review'; @@ -39,27 +39,6 @@ const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN'; -/** - * Download a Discord attachment to local disk. - * Returns the absolute path to the saved file. - */ -async function downloadAttachment( - att: Attachment, - defaultExt = '.bin', -): Promise { - const res = await fetch(att.url); - if (!res.ok) throw new Error(`Download failed: ${res.status}`); - const buffer = Buffer.from(await res.arrayBuffer()); - - fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); - const ext = path.extname(att.name || `file${defaultExt}`) || defaultExt; - const filename = `${Date.now()}-${att.id}${ext}`; - const filePath = path.join(ATTACHMENTS_DIR, filename); - fs.writeFileSync(filePath, buffer); - logger.info({ file: filename, size: buffer.length }, 'Attachment downloaded'); - return filePath; -} - /** * Wait for a pending transcription from the other service (poll cache file). * Returns the cached text, or null if timeout. @@ -295,63 +274,32 @@ export class DiscordChannel implements Channel { const attachmentDescriptions = await Promise.all( [...message.attachments.values()].map(async (att) => { const contentType = att.contentType || ''; - // Voice messages → transcribe; regular audio files → download + // Voice messages → transcribe; all attachments still get a file path. if ( contentType.startsWith('audio/') && (isVoiceMessage || att.duration != null) - ) { - return transcribeAudio(att); - } else if ( - contentType.startsWith('audio/') || - contentType.startsWith('image/') ) { try { - const filePath = await downloadAttachment( + const transcript = await transcribeAudio(att); + const reference = await describeDownloadedAttachment( att, - contentType.startsWith('image/') ? '.png' : '.wav', + contentType, ); - const label = contentType.startsWith('image/') - ? 'Image' - : 'Audio'; - const origName = att.name || 'file'; - return `[${label}: ${origName} → ${filePath}]`; + return `${transcript}\n${reference}`; } catch (err) { logger.error( { err, file: att.name }, - 'Attachment download failed', + 'Voice attachment handling failed', ); return `[File: ${att.name || 'file'} (download failed)]`; } - } else if (contentType.startsWith('video/')) { - return `[Video: ${att.name || 'video'}]`; - } else if ( - contentType.startsWith('text/') || - /\.(txt|md|json|csv|log|xml|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|py|js|ts|jsx|tsx|html|css|sql|rs|go|java|c|cpp|h|hpp|rb|php|swift|kt|scala|r|lua|pl|ex|exs|hs|ml|clj|dart|v|zig|nim|ps1|bat|cmd|mjs|cjs)$/i.test( - att.name || '', - ) - ) { - // Download and inline text-based files - try { - const res = await fetch(att.url); - if (!res.ok) throw new Error(`Download failed: ${res.status}`); - let text = await res.text(); - // Truncate very large files - const MAX_TEXT_LENGTH = 32_000; - if (text.length > MAX_TEXT_LENGTH) { - text = - text.slice(0, MAX_TEXT_LENGTH) + - `\n...(truncated, ${text.length} chars total)`; - } - return `[File: ${att.name}]\n${text}`; - } catch (err) { - logger.error( - { err, file: att.name }, - 'Text file download failed', - ); - return `[File: ${att.name || 'file'} (download failed)]`; - } - } else { - return `[File: ${att.name || 'file'}]`; + } + + try { + return await describeDownloadedAttachment(att, contentType); + } catch (err) { + logger.error({ err, file: att.name }, 'Attachment download failed'); + return `[File: ${att.name || 'file'} (download failed)]`; } }), );