feat: add MEDIA outbound attachment directive
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
extractMarkdownImageAttachments,
|
||||
extractMediaAttachments,
|
||||
extractImageTagPaths,
|
||||
IMAGE_TAG_RE,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
|
||||
@@ -9,7 +9,13 @@ export interface PreparedDiscordOutbound {
|
||||
text: string;
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none';
|
||||
attachmentSource:
|
||||
| 'structured'
|
||||
| 'media-tag'
|
||||
| 'md-link'
|
||||
| 'image-tag'
|
||||
| 'mixed'
|
||||
| 'none';
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
@@ -51,9 +57,11 @@ export function prepareDiscordOutbound(
|
||||
? 'structured'
|
||||
: normalized.attachmentSource === 'legacy-ejclaw-json'
|
||||
? 'structured'
|
||||
: normalized.attachmentSource === 'markdown-image'
|
||||
? 'md-link'
|
||||
: (normalized.attachmentSource ?? 'none');
|
||||
: normalized.attachmentSource === 'media-tag'
|
||||
? 'media-tag'
|
||||
: normalized.attachmentSource === 'markdown-image'
|
||||
? 'md-link'
|
||||
: (normalized.attachmentSource ?? 'none');
|
||||
|
||||
return {
|
||||
text: outboundText,
|
||||
|
||||
@@ -102,6 +102,10 @@ const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MINIMAL_MP4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
||||
]);
|
||||
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
@@ -258,6 +262,39 @@ ${JSON.stringify({
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes MEDIA directives and sends them as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`adventurer-active-sfx-preview-${Date.now()}.mp4`,
|
||||
);
|
||||
fs.writeFileSync(filePath, MINIMAL_MP4);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-media' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
`사운드 프리뷰입니다.\nMEDIA:${filePath}`,
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '사운드 프리뷰입니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: path.basename(filePath),
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain('MEDIA:');
|
||||
});
|
||||
|
||||
it('keeps non-image local markdown links readable without uploading files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
|
||||
@@ -10,6 +10,12 @@ const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MINIMAL_MP4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
||||
]);
|
||||
const MINIMAL_PDF = Buffer.from('%PDF-1.4\n', 'ascii');
|
||||
const MINIMAL_ZIP = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00]);
|
||||
|
||||
const cleanupDirs: string[] = [];
|
||||
const cleanupFiles: string[] = [];
|
||||
@@ -133,6 +139,43 @@ describe('validateOutboundAttachments', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts supported non-image media and document files', () => {
|
||||
const dir = makeTempDir(os.tmpdir(), 'ejclaw-media-');
|
||||
const mp4Path = writeFile(dir, 'preview.mp4', MINIMAL_MP4);
|
||||
const pdfPath = writeFile(dir, 'report.pdf', MINIMAL_PDF);
|
||||
const zipPath = writeFile(dir, 'archive.zip', MINIMAL_ZIP);
|
||||
const textPath = writeFile(dir, 'notes.txt', 'hello\n');
|
||||
|
||||
const result = validateOutboundAttachments([
|
||||
{ path: mp4Path, mime: 'video/mp4' },
|
||||
{ path: pdfPath, mime: 'application/pdf' },
|
||||
{ path: zipPath, mime: 'application/zip' },
|
||||
{ path: textPath, mime: 'text/plain' },
|
||||
]);
|
||||
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.files).toEqual([
|
||||
{
|
||||
attachment: fs.realpathSync(mp4Path),
|
||||
name: 'preview.mp4',
|
||||
},
|
||||
{
|
||||
attachment: fs.realpathSync(pdfPath),
|
||||
name: 'report.pdf',
|
||||
},
|
||||
{
|
||||
attachment: fs.realpathSync(zipPath),
|
||||
name: 'archive.zip',
|
||||
},
|
||||
{
|
||||
attachment: fs.realpathSync(textPath),
|
||||
name: 'notes.txt',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOutboundAttachments policy checks', () => {
|
||||
it('requires workspace paths to be explicitly allowlisted', () => {
|
||||
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
||||
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
||||
@@ -243,6 +286,16 @@ describe('validateOutboundAttachments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects supported non-image files with invalid signatures', () => {
|
||||
const dir = makeTempDir(os.tmpdir(), 'ejclaw-media-');
|
||||
const fakeMp4 = writeFile(dir, 'fake.mp4', 'not a movie');
|
||||
|
||||
expect(validateOutboundAttachments([{ path: fakeMp4 }])).toEqual({
|
||||
files: [],
|
||||
rejected: [{ path: fakeMp4, reason: 'invalid-file-signature' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-files and files over the size cap', () => {
|
||||
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||
const nestedDir = path.join(dir, 'nested.png');
|
||||
|
||||
@@ -18,6 +18,29 @@ export interface ValidateOutboundAttachmentsResult {
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const SUPPORTED_ATTACHMENT_MIMES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mov': 'video/quicktime',
|
||||
'.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.flac': 'audio/flac',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.csv': 'text/csv',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
|
||||
function unique(values: Array<string | null | undefined>): string[] {
|
||||
return [
|
||||
...new Set(values.filter((value): value is string => Boolean(value))),
|
||||
@@ -127,6 +150,81 @@ function detectImageMime(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(filePath: string, byteCount = 512): Buffer {
|
||||
const handle = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(byteCount);
|
||||
const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
fs.closeSync(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyTextFile(header: Buffer): boolean {
|
||||
return !header.includes(0);
|
||||
}
|
||||
|
||||
function detectSupportedMime(filePath: string): string | null {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const expectedMime = SUPPORTED_ATTACHMENT_MIMES[ext];
|
||||
if (!expectedMime) return null;
|
||||
if (IMAGE_EXTS.test(filePath)) {
|
||||
return detectImageMime(filePath);
|
||||
}
|
||||
|
||||
const header = readHeader(filePath);
|
||||
switch (ext) {
|
||||
case '.mp4':
|
||||
case '.mov':
|
||||
case '.m4a':
|
||||
return header.subarray(4, 8).toString('ascii') === 'ftyp'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.webm':
|
||||
return header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.mp3':
|
||||
return header.subarray(0, 3).toString('ascii') === 'ID3' ||
|
||||
(header[0] === 0xff && (header[1] & 0xe0) === 0xe0)
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.wav':
|
||||
return header.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
header.subarray(8, 12).toString('ascii') === 'WAVE'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.ogg':
|
||||
return header.subarray(0, 4).toString('ascii') === 'OggS'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.flac':
|
||||
return header.subarray(0, 4).toString('ascii') === 'fLaC'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.pdf':
|
||||
return header.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.zip':
|
||||
return header
|
||||
.subarray(0, 4)
|
||||
.equals(Buffer.from([0x50, 0x4b, 0x03, 0x04])) ||
|
||||
header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x05, 0x06])) ||
|
||||
header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x07, 0x08]))
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.txt':
|
||||
case '.md':
|
||||
case '.csv':
|
||||
case '.json':
|
||||
return isLikelyTextFile(header) ? expectedMime : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAttachmentName(
|
||||
attachment: OutboundAttachment,
|
||||
realPath: string,
|
||||
@@ -157,7 +255,9 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'not-absolute' });
|
||||
continue;
|
||||
}
|
||||
if (!IMAGE_EXTS.test(requestedPath)) {
|
||||
const expectedMime =
|
||||
SUPPORTED_ATTACHMENT_MIMES[path.extname(requestedPath).toLowerCase()];
|
||||
if (!expectedMime) {
|
||||
rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
|
||||
continue;
|
||||
}
|
||||
@@ -183,11 +283,13 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||
continue;
|
||||
}
|
||||
const detectedMime = detectImageMime(realPath);
|
||||
const detectedMime = detectSupportedMime(realPath);
|
||||
if (!detectedMime) {
|
||||
rejected.push({
|
||||
path: requestedPath,
|
||||
reason: 'invalid-image-signature',
|
||||
reason: IMAGE_EXTS.test(realPath)
|
||||
? 'invalid-image-signature'
|
||||
: 'invalid-file-signature',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user