fix: expose discord file attachment paths
This commit is contained in:
125
src/channels/discord-attachments.ts
Normal file
125
src/channels/discord-attachments.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -604,7 +604,10 @@ describe('attachments', () => {
|
|||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
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'),
|
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 opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
|
|
||||||
const attachments = new Map([
|
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({
|
const msg = createMessage({
|
||||||
content: '',
|
content: '',
|
||||||
@@ -663,18 +674,26 @@ describe('attachments', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
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 opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
|
|
||||||
const attachments = new Map([
|
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({
|
const msg = createMessage({
|
||||||
content: '',
|
content: '',
|
||||||
@@ -686,7 +705,74 @@ describe('attachments', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
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',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: expect.stringMatching(
|
content: expect.stringMatching(
|
||||||
/^\[Image: .+\.png\]\n\[File: b\.txt\]\nHello from text file$/,
|
/^\[Image: .+\.png\]\n\[File: b\.txt → .+\.txt\]\nHello from text file$/,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
TextChannel,
|
TextChannel,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { CACHE_DIR, DATA_DIR } from '../config.js';
|
import { CACHE_DIR } from '../config.js';
|
||||||
import { getEnv } from '../env.js';
|
import { getEnv } from '../env.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||||
@@ -27,10 +27,10 @@ import {
|
|||||||
deleteOwnDashboardDuplicateOnCreate,
|
deleteOwnDashboardDuplicateOnCreate,
|
||||||
isDashboardTrackedSend,
|
isDashboardTrackedSend,
|
||||||
} from './discord-dashboard-create-cleanup.js';
|
} from './discord-dashboard-create-cleanup.js';
|
||||||
|
import { describeDownloadedAttachment } from './discord-attachments.js';
|
||||||
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
|
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
|
||||||
import { prepareDiscordOutbound } from './discord-outbound.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 TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||||
const DISCORD_OWNER_CHANNEL = 'discord';
|
const DISCORD_OWNER_CHANNEL = 'discord';
|
||||||
const DISCORD_REVIEWER_CHANNEL = 'discord-review';
|
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_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
|
||||||
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_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<string> {
|
|
||||||
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).
|
* Wait for a pending transcription from the other service (poll cache file).
|
||||||
* Returns the cached text, or null if timeout.
|
* Returns the cached text, or null if timeout.
|
||||||
@@ -295,63 +274,32 @@ export class DiscordChannel implements Channel {
|
|||||||
const attachmentDescriptions = await Promise.all(
|
const attachmentDescriptions = await Promise.all(
|
||||||
[...message.attachments.values()].map(async (att) => {
|
[...message.attachments.values()].map(async (att) => {
|
||||||
const contentType = att.contentType || '';
|
const contentType = att.contentType || '';
|
||||||
// Voice messages → transcribe; regular audio files → download
|
// Voice messages → transcribe; all attachments still get a file path.
|
||||||
if (
|
if (
|
||||||
contentType.startsWith('audio/') &&
|
contentType.startsWith('audio/') &&
|
||||||
(isVoiceMessage || att.duration != null)
|
(isVoiceMessage || att.duration != null)
|
||||||
) {
|
|
||||||
return transcribeAudio(att);
|
|
||||||
} else if (
|
|
||||||
contentType.startsWith('audio/') ||
|
|
||||||
contentType.startsWith('image/')
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const filePath = await downloadAttachment(
|
const transcript = await transcribeAudio(att);
|
||||||
|
const reference = await describeDownloadedAttachment(
|
||||||
att,
|
att,
|
||||||
contentType.startsWith('image/') ? '.png' : '.wav',
|
contentType,
|
||||||
);
|
);
|
||||||
const label = contentType.startsWith('image/')
|
return `${transcript}\n${reference}`;
|
||||||
? 'Image'
|
|
||||||
: 'Audio';
|
|
||||||
const origName = att.name || 'file';
|
|
||||||
return `[${label}: ${origName} → ${filePath}]`;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err, file: att.name },
|
{ err, file: att.name },
|
||||||
'Attachment download failed',
|
'Voice attachment handling failed',
|
||||||
);
|
);
|
||||||
return `[File: ${att.name || 'file'} (download failed)]`;
|
return `[File: ${att.name || 'file'} (download failed)]`;
|
||||||
}
|
}
|
||||||
} else if (contentType.startsWith('video/')) {
|
}
|
||||||
return `[Video: ${att.name || 'video'}]`;
|
|
||||||
} else if (
|
try {
|
||||||
contentType.startsWith('text/') ||
|
return await describeDownloadedAttachment(att, contentType);
|
||||||
/\.(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(
|
} catch (err) {
|
||||||
att.name || '',
|
logger.error({ err, file: att.name }, 'Attachment download failed');
|
||||||
)
|
return `[File: ${att.name || 'file'} (download failed)]`;
|
||||||
) {
|
|
||||||
// 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'}]`;
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user