Merge pull request #155 from phj1081/codex/owner/ejclaw

fix: expose Discord file attachment paths
This commit is contained in:
Eyejoker
2026-05-23 15:53:34 +09:00
committed by GitHub
3 changed files with 234 additions and 75 deletions

View 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}`;
}

View File

@@ -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$/,
),
}),
);

View 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)]`;
}
}),
);