Normalize agent attachment output (#122)

This commit is contained in:
Eyejoker
2026-05-02 19:50:32 +09:00
committed by GitHub
parent 9e45534de0
commit d3c02265e5
16 changed files with 414 additions and 124 deletions

View File

@@ -1,64 +1,32 @@
import path from 'path';
import {
extractImageTagPaths,
normalizeEjclawStructuredOutput,
} from '../agent-protocol.js';
import { normalizeAgentOutput } from '../agent-protocol.js';
import type { OutboundAttachment } from '../types.js';
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
export interface PreparedDiscordOutbound {
text: string;
cleanText: string;
attachments: OutboundAttachment[];
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'none';
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none';
silent: boolean;
}
function extractMarkdownImageAttachments(text: string): {
cleanText: string;
attachments: OutboundAttachment[];
} {
const attachments: OutboundAttachment[] = [];
const seen = new Set<string>();
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
function sanitizeLocalMarkdownLinks(text: string): string {
return text.replace(LOCAL_MARKDOWN_LINK_RE, (_full, rawPath: string) => {
const trimmed = rawPath.trim();
if (IMAGE_EXTS.test(trimmed)) {
if (!seen.has(trimmed)) {
attachments.push({
path: trimmed,
name: path.basename(trimmed),
});
seen.add(trimmed);
}
return '';
}
const basename = path.basename(trimmed.replace(/#.*$/, ''));
const lineMatch = trimmed.match(/#L(\d+)/);
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
});
return { cleanText, attachments };
}
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
return paths
.filter((filePath) => IMAGE_EXTS.test(filePath))
.map((filePath) => ({
path: filePath,
name: path.basename(filePath),
}));
}
export function prepareDiscordOutbound(
text: string,
optionAttachments: OutboundAttachment[] | undefined,
): PreparedDiscordOutbound {
const normalized = normalizeEjclawStructuredOutput(text);
const normalized = normalizeAgentOutput(text);
if (normalized.output?.visibility === 'silent') {
return {
text: '',
@@ -72,30 +40,26 @@ export function prepareDiscordOutbound(
const structuredOutput =
normalized.output?.visibility === 'public' ? normalized.output : null;
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
const structuredAttachments =
const cleanText = sanitizeLocalMarkdownLinks(outboundText);
const attachments =
optionAttachments && optionAttachments.length > 0
? optionAttachments
: (structuredOutput?.attachments ?? []);
const hasStructuredAttachments = structuredAttachments.length > 0;
const markdownExtracted = extractMarkdownImageAttachments(outboundText);
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
const legacyImageTagAttachments = imageTagPathsToAttachments(
imageTagExtracted.imagePaths,
);
const hasAttachments = attachments.length > 0;
const attachmentSource =
optionAttachments && optionAttachments.length > 0
? 'structured'
: normalized.attachmentSource === 'legacy-ejclaw-json'
? 'structured'
: normalized.attachmentSource === 'markdown-image'
? 'md-link'
: (normalized.attachmentSource ?? 'none');
return {
text: outboundText,
cleanText: imageTagExtracted.cleanText,
attachments: hasStructuredAttachments
? structuredAttachments
: [...markdownExtracted.attachments, ...legacyImageTagAttachments],
attachmentSource: hasStructuredAttachments
? 'structured'
: markdownExtracted.attachments.length > 0
? 'md-link'
: legacyImageTagAttachments.length > 0
? 'image-tag'
: 'none',
cleanText,
attachments,
attachmentSource: hasAttachments ? attachmentSource : 'none',
silent: false,
};
}

View File

@@ -225,4 +225,57 @@ ${JSON.stringify({
'"ejclaw"',
);
});
it('normalizes markdown images and sends them as files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`ejclaw-discord-markdown-image-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-3' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`스크린샷입니다.\n![screenshot](${filePath})`,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '스크린샷입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: path.basename(filePath),
},
],
flags: 1 << 2,
});
});
it('keeps non-image local markdown links readable without uploading files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-4' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
'수정 위치: [source](/tmp/project/src/index.ts#L42)',
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '수정 위치: `index.ts:42`',
files: undefined,
flags: 1 << 2,
});
});
});