feat: add MEDIA outbound attachment directive

This commit is contained in:
ejclaw
2026-05-26 17:26:20 +09:00
parent 89b3dd382e
commit df6ec5f2b2
11 changed files with 373 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
export {
extractMarkdownImageAttachments,
extractMediaAttachments,
extractImageTagPaths,
IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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');

View File

@@ -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;
}