Merge pull request #155 from phj1081/codex/owner/ejclaw
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',
|
||||
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$/,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<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).
|
||||
* 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)]`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user