Normalize agent attachment output (#122)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user