Fix structured attachment rendering (#69)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering
This commit is contained in:
38
apps/dashboard/src/RoomAttachmentGallery.tsx
Normal file
38
apps/dashboard/src/RoomAttachmentGallery.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { RoomThreadEntry } from './roomThread';
|
||||||
|
|
||||||
|
type RoomAttachment = NonNullable<RoomThreadEntry['attachments']>[number];
|
||||||
|
|
||||||
|
function attachmentDisplayName(attachment: RoomAttachment): string {
|
||||||
|
if (attachment.name) return attachment.name;
|
||||||
|
const segments = attachment.path.split(/[\\/]/);
|
||||||
|
return segments.at(-1) || 'attachment';
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentUrl(attachment: RoomAttachment): string {
|
||||||
|
return `/api/attachments?path=${encodeURIComponent(attachment.path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomAttachmentGallery({
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
attachments?: RoomThreadEntry['attachments'];
|
||||||
|
}) {
|
||||||
|
if (!attachments || attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="room-attachments">
|
||||||
|
{attachments.map((attachment) => {
|
||||||
|
const name = attachmentDisplayName(attachment);
|
||||||
|
const url = attachmentUrl(attachment);
|
||||||
|
return (
|
||||||
|
<li className="room-attachment" key={`${attachment.path}:${name}`}>
|
||||||
|
<a href={url} rel="noreferrer" target="_blank">
|
||||||
|
<img alt={name} loading="lazy" src={url} />
|
||||||
|
<span>{name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -161,4 +161,51 @@ describe('RoomCardV2', () => {
|
|||||||
expect(html).toContain('<code>pnpm openapi:sync</code>');
|
expect(html).toContain('<code>pnpm openapi:sync</code>');
|
||||||
expect(html).not.toContain('`origin/dev`');
|
expect(html).not.toContain('`origin/dev`');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders message attachments as dashboard images', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(RoomCardV2, {
|
||||||
|
activity: activity({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'bot-attachment',
|
||||||
|
sender: 'bot-1',
|
||||||
|
senderName: 'owner',
|
||||||
|
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
timestamp: '2026-04-28T02:00:00.000Z',
|
||||||
|
isFromMe: false,
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
activityLoading: false,
|
||||||
|
busy: false,
|
||||||
|
draft: '',
|
||||||
|
entry,
|
||||||
|
expanded: true,
|
||||||
|
inboxItems: [],
|
||||||
|
locale: 'ko',
|
||||||
|
onDraftChange: () => {},
|
||||||
|
onSendMessage: () => {},
|
||||||
|
onToggle: () => {},
|
||||||
|
pinned: true,
|
||||||
|
t,
|
||||||
|
...formatters,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('class="room-attachments"');
|
||||||
|
expect(html).toContain(
|
||||||
|
'/api/attachments?path=%2Ftmp%2Fbar-chart-label-fit-playwright.png',
|
||||||
|
);
|
||||||
|
expect(html).toContain('bar-chart-label-fit-playwright.png');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Send } from 'lucide-react';
|
|||||||
import type { DashboardOverview, DashboardRoomActivity } from './api';
|
import type { DashboardOverview, DashboardRoomActivity } from './api';
|
||||||
import type { Locale, Messages } from './i18n';
|
import type { Locale, Messages } from './i18n';
|
||||||
import { ParsedBody } from './ParsedBody';
|
import { ParsedBody } from './ParsedBody';
|
||||||
|
import { RoomAttachmentGallery } from './RoomAttachmentGallery';
|
||||||
import {
|
import {
|
||||||
buildRoomThreadEntries,
|
buildRoomThreadEntries,
|
||||||
isWatcherRoomMessage,
|
isWatcherRoomMessage,
|
||||||
@@ -779,6 +780,7 @@ function RoomTimelineEntry({
|
|||||||
</header>
|
</header>
|
||||||
<div className="room-timeline-body">
|
<div className="room-timeline-body">
|
||||||
<ParsedBody text={entry.content} />
|
<ParsedBody text={entry.content} />
|
||||||
|
<RoomAttachmentGallery attachments={entry.attachments} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ export interface DashboardRoomActivity {
|
|||||||
sender: string;
|
sender: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
mime?: string;
|
||||||
|
}>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
isFromMe: boolean;
|
isFromMe: boolean;
|
||||||
isBotMessage: boolean;
|
isBotMessage: boolean;
|
||||||
@@ -141,6 +146,11 @@ export interface DashboardRoomActivity {
|
|||||||
verdict: string | null;
|
verdict: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
outputText: string;
|
outputText: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
mime?: string;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type RoomThreadEntry = {
|
|||||||
id: string;
|
id: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments?: RoomMessage['attachments'];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
isFromMe: boolean;
|
isFromMe: boolean;
|
||||||
isBotMessage: boolean;
|
isBotMessage: boolean;
|
||||||
@@ -51,6 +52,7 @@ function toMessageEntry(message: RoomMessage): RoomThreadEntry {
|
|||||||
id: message.id,
|
id: message.id,
|
||||||
senderName: message.senderName,
|
senderName: message.senderName,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
|
attachments: message.attachments,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
isFromMe: message.isFromMe,
|
isFromMe: message.isFromMe,
|
||||||
isBotMessage: message.isBotMessage,
|
isBotMessage: message.isBotMessage,
|
||||||
@@ -64,6 +66,7 @@ function toOutputEntry(output: RoomOutput): RoomThreadEntry | null {
|
|||||||
id: `out:${output.id}`,
|
id: `out:${output.id}`,
|
||||||
senderName: output.role,
|
senderName: output.role,
|
||||||
content: output.outputText,
|
content: output.outputText,
|
||||||
|
attachments: output.attachments,
|
||||||
timestamp: output.createdAt,
|
timestamp: output.createdAt,
|
||||||
isFromMe: false,
|
isFromMe: false,
|
||||||
isBotMessage: true,
|
isBotMessage: true,
|
||||||
|
|||||||
@@ -4039,6 +4039,38 @@ progress::-moz-progress-bar {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-attachments {
|
||||||
|
list-style: none;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-attachment a {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: min(220px, 100%);
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-attachment img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 180px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-attachment span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
/* thread — flat */
|
/* thread — flat */
|
||||||
.room-thread {
|
.room-thread {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
101
src/channels/discord-outbound.ts
Normal file
101
src/channels/discord-outbound.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractImageTagPaths,
|
||||||
|
normalizeEjclawStructuredOutput,
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
export interface PreparedDiscordOutbound {
|
||||||
|
text: string;
|
||||||
|
cleanText: string;
|
||||||
|
attachments: OutboundAttachment[];
|
||||||
|
attachmentSource: 'structured' | 'md-link' | 'image-tag' | '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) => {
|
||||||
|
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);
|
||||||
|
if (normalized.output?.visibility === 'silent') {
|
||||||
|
return {
|
||||||
|
text: '',
|
||||||
|
cleanText: '',
|
||||||
|
attachments: [],
|
||||||
|
attachmentSource: 'none',
|
||||||
|
silent: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuredOutput =
|
||||||
|
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||||
|
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
|
||||||
|
const structuredAttachments =
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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',
|
||||||
|
silent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
171
src/channels/discord-structured-output.test.ts
Normal file
171
src/channels/discord-structured-output.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('./registry.js', () => ({
|
||||||
|
registerChannel: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../env.js', () => ({
|
||||||
|
readEnvFile: vi.fn(() => ({})),
|
||||||
|
getEnv: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../config.js', () => ({
|
||||||
|
ASSISTANT_NAME: 'Andy',
|
||||||
|
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||||
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
|
CACHE_DIR: '/tmp/ejclaw-test-cache',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../service-routing.js', () => ({
|
||||||
|
hasReviewerLease: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Handler = (...args: any[]) => any;
|
||||||
|
|
||||||
|
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||||
|
|
||||||
|
vi.mock('discord.js', () => {
|
||||||
|
class MockClient {
|
||||||
|
eventHandlers = new Map<string, Handler[]>();
|
||||||
|
user: any = { id: '999888777', tag: 'Andy#1234' };
|
||||||
|
private _ready = false;
|
||||||
|
|
||||||
|
constructor(_opts: any) {
|
||||||
|
clientRef.current = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, handler: Handler) {
|
||||||
|
this.eventHandlers.set(event, [
|
||||||
|
...(this.eventHandlers.get(event) ?? []),
|
||||||
|
handler,
|
||||||
|
]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
once(event: string, handler: Handler) {
|
||||||
|
return this.on(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(_token: string) {
|
||||||
|
this._ready = true;
|
||||||
|
for (const handler of this.eventHandlers.get('ready') ?? []) {
|
||||||
|
handler({ user: this.user });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady() {
|
||||||
|
return this._ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = {
|
||||||
|
fetch: vi.fn().mockResolvedValue({
|
||||||
|
send: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Client: MockClient,
|
||||||
|
Events: {
|
||||||
|
MessageCreate: 'messageCreate',
|
||||||
|
ClientReady: 'ready',
|
||||||
|
Error: 'error',
|
||||||
|
},
|
||||||
|
GatewayIntentBits: {
|
||||||
|
Guilds: 1,
|
||||||
|
GuildMessages: 2,
|
||||||
|
MessageContent: 4,
|
||||||
|
DirectMessages: 8,
|
||||||
|
},
|
||||||
|
MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
|
||||||
|
TextChannel: class TextChannel {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { DiscordChannel, type DiscordChannelOpts } from './discord.js';
|
||||||
|
|
||||||
|
const ONE_PIXEL_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempFiles: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
for (const file of tempFiles.splice(0)) {
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTestOpts(): DiscordChannelOpts {
|
||||||
|
return {
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
onChatMetadata: vi.fn(),
|
||||||
|
roomBindings: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DiscordChannel structured output', () => {
|
||||||
|
it('normalizes raw EJClaw JSON and sends direct temp images as files', async () => {
|
||||||
|
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||||
|
await channel.connect();
|
||||||
|
const filePath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||||
|
tempFiles.push(filePath);
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
await channel.sendMessage(
|
||||||
|
'dc:1234567890123456',
|
||||||
|
JSON.stringify({
|
||||||
|
ejclaw: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||||
|
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
attachment: fs.realpathSync(filePath),
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flags: 1 << 2,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
|
||||||
|
'"ejclaw"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,11 +19,11 @@ import {
|
|||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
import { getEnv } from '../env.js';
|
import { getEnv } from '../env.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { extractImageTagPaths } from '../agent-protocol.js';
|
|
||||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||||
import { formatOutbound } from '../router.js';
|
import { formatOutbound } from '../router.js';
|
||||||
import { hasReviewerLease } from '../service-routing.js';
|
import { hasReviewerLease } from '../service-routing.js';
|
||||||
import type { OutboundAttachment, SendMessageOptions } from '../types.js';
|
import type { SendMessageOptions } from '../types.js';
|
||||||
|
import { prepareDiscordOutbound } from './discord-outbound.js';
|
||||||
|
|
||||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||||
@@ -33,45 +33,6 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter';
|
|||||||
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
|
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
|
||||||
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
|
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
|
||||||
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN';
|
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN';
|
||||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
||||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
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),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a Discord attachment to local disk.
|
* Download a Discord attachment to local disk.
|
||||||
@@ -466,26 +427,15 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
const textChannel = channel as TextChannel;
|
const textChannel = channel as TextChannel;
|
||||||
|
|
||||||
const structuredAttachments = options.attachments ?? [];
|
const outbound = prepareDiscordOutbound(text, options.attachments);
|
||||||
const hasStructuredAttachments = structuredAttachments.length > 0;
|
if (outbound.silent) {
|
||||||
const markdownExtracted = extractMarkdownImageAttachments(text);
|
logger.debug(
|
||||||
const imageTagExtracted = extractImageTagPaths(
|
{ jid, channelName: this.name },
|
||||||
markdownExtracted.cleanText,
|
'Skipping silent structured Discord outbound message',
|
||||||
);
|
);
|
||||||
const legacyImageTagAttachments = imageTagPathsToAttachments(
|
return;
|
||||||
imageTagExtracted.imagePaths,
|
}
|
||||||
);
|
const validation = validateOutboundAttachments(outbound.attachments, {
|
||||||
const outboundAttachments = hasStructuredAttachments
|
|
||||||
? structuredAttachments
|
|
||||||
: [...markdownExtracted.attachments, ...legacyImageTagAttachments];
|
|
||||||
const attachmentSource = hasStructuredAttachments
|
|
||||||
? 'structured'
|
|
||||||
: markdownExtracted.attachments.length > 0
|
|
||||||
? 'md-link'
|
|
||||||
: legacyImageTagAttachments.length > 0
|
|
||||||
? 'image-tag'
|
|
||||||
: 'none';
|
|
||||||
const validation = validateOutboundAttachments(outboundAttachments, {
|
|
||||||
baseDirs: options.attachmentBaseDirs,
|
baseDirs: options.attachmentBaseDirs,
|
||||||
});
|
});
|
||||||
const files = validation.files;
|
const files = validation.files;
|
||||||
@@ -495,14 +445,14 @@ export class DiscordChannel implements Channel {
|
|||||||
{
|
{
|
||||||
jid,
|
jid,
|
||||||
channelName: this.name,
|
channelName: this.name,
|
||||||
attachmentSource,
|
attachmentSource: outbound.attachmentSource,
|
||||||
rejected: validation.rejected,
|
rejected: validation.rejected,
|
||||||
},
|
},
|
||||||
'Rejected outbound Discord attachments',
|
'Rejected outbound Discord attachments',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleaned = imageTagExtracted.cleanText
|
let cleaned = outbound.cleanText
|
||||||
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
|
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
|
||||||
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
|
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
|
||||||
.trim();
|
.trim();
|
||||||
@@ -588,11 +538,11 @@ export class DiscordChannel implements Channel {
|
|||||||
{
|
{
|
||||||
jid,
|
jid,
|
||||||
channelName: this.name,
|
channelName: this.name,
|
||||||
length: text.length,
|
length: outbound.text.length,
|
||||||
deliveryMode: 'send',
|
deliveryMode: 'send',
|
||||||
chunkCount,
|
chunkCount,
|
||||||
attachmentCount: files.length,
|
attachmentCount: files.length,
|
||||||
attachmentSource,
|
attachmentSource: outbound.attachmentSource,
|
||||||
messageId: sentMessageIds[0] ?? null,
|
messageId: sentMessageIds[0] ?? null,
|
||||||
messageIds: sentMessageIds,
|
messageIds: sentMessageIds,
|
||||||
botUserId: this.client.user?.id ?? null,
|
botUserId: this.client.user?.id ?? null,
|
||||||
|
|||||||
@@ -599,6 +599,50 @@ describe('IPC message authorization', () => {
|
|||||||
expect(result.outcome).toBe('sent');
|
expect(result.outcome).toBe('sent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => {
|
||||||
|
const sendMessage = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const result = await forwardAuthorizedIpcMessage(
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
chatJid: 'other@g.us',
|
||||||
|
text: JSON.stringify({
|
||||||
|
ejclaw: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '첨부를 렌더링했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'other-group',
|
||||||
|
false,
|
||||||
|
groups,
|
||||||
|
sendMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
'other@g.us',
|
||||||
|
'첨부를 렌더링했습니다.',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('sent');
|
||||||
|
});
|
||||||
|
|
||||||
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
||||||
const sendMessage = vi.fn(async () => {});
|
const sendMessage = vi.fn(async () => {});
|
||||||
const injectInboundMessage = vi.fn(async () => {});
|
const injectInboundMessage = vi.fn(async () => {});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
IpcMessageForwardResult,
|
IpcMessageForwardResult,
|
||||||
IpcMessagePayload,
|
IpcMessagePayload,
|
||||||
} from './ipc-types.js';
|
} from './ipc-types.js';
|
||||||
|
import { normalizeEjclawStructuredOutput } from './agent-protocol.js';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
export async function forwardAuthorizedIpcMessage(
|
export async function forwardAuthorizedIpcMessage(
|
||||||
@@ -80,13 +81,25 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessage(
|
const normalized = normalizeEjclawStructuredOutput(msg.text);
|
||||||
msg.chatJid,
|
if (normalized.output?.visibility === 'silent') {
|
||||||
msg.text,
|
return {
|
||||||
msg.senderRole,
|
outcome: 'sent',
|
||||||
msg.runId,
|
chatJid: msg.chatJid,
|
||||||
msg.attachments,
|
targetGroup: targetGroup?.folder ?? null,
|
||||||
);
|
isMainOverride,
|
||||||
|
senderRole: msg.senderRole ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const structured =
|
||||||
|
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||||
|
const text = structured?.text ?? normalized.result ?? msg.text;
|
||||||
|
const attachments =
|
||||||
|
msg.attachments && msg.attachments.length > 0
|
||||||
|
? msg.attachments
|
||||||
|
: (structured?.attachments ?? undefined);
|
||||||
|
|
||||||
|
await sendMessage(msg.chatJid, text, msg.senderRole, msg.runId, attachments);
|
||||||
return {
|
return {
|
||||||
outcome: 'sent',
|
outcome: 'sent',
|
||||||
chatJid: msg.chatJid,
|
chatJid: msg.chatJid,
|
||||||
|
|||||||
@@ -86,6 +86,31 @@ describe('validateOutboundAttachments', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts direct generated screenshot files under the temp directory', () => {
|
||||||
|
const imagePath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(imagePath, ONE_PIXEL_PNG);
|
||||||
|
cleanupFiles.push(imagePath);
|
||||||
|
|
||||||
|
const result = validateOutboundAttachments([
|
||||||
|
{
|
||||||
|
path: imagePath,
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.rejected).toEqual([]);
|
||||||
|
expect(result.files).toEqual([
|
||||||
|
{
|
||||||
|
attachment: fs.realpathSync(imagePath),
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('requires workspace paths to be explicitly allowlisted', () => {
|
it('requires workspace paths to be explicitly allowlisted', () => {
|
||||||
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
||||||
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [
|
|||||||
'ejclaw-attachment-',
|
'ejclaw-attachment-',
|
||||||
'ejclaw-discord-image-',
|
'ejclaw-discord-image-',
|
||||||
] as const;
|
] as const;
|
||||||
const DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES = ['ejclaw-'] as const;
|
|
||||||
|
|
||||||
function unique(values: Array<string | null | undefined>): string[] {
|
function unique(values: Array<string | null | undefined>): string[] {
|
||||||
return [
|
return [
|
||||||
@@ -44,8 +43,8 @@ export function getDefaultAttachmentBaseDirs(): string[] {
|
|||||||
process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null);
|
process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null);
|
||||||
// Keep defaults narrow. Runtime-specific workspaces must be passed via
|
// Keep defaults narrow. Runtime-specific workspaces must be passed via
|
||||||
// attachmentBaseDirs so one room cannot attach another room's files by path.
|
// attachmentBaseDirs so one room cannot attach another room's files by path.
|
||||||
// Ad-hoc generated files under os.tmpdir()/ejclaw-attachment-* are handled
|
// Ad-hoc generated files under os.tmpdir() are handled separately after
|
||||||
// separately after resolving symlinks, without allowlisting all of /tmp.
|
// resolving symlinks, without allowlisting all of /tmp recursively.
|
||||||
return unique([
|
return unique([
|
||||||
path.join(DATA_DIR, 'attachments'),
|
path.join(DATA_DIR, 'attachments'),
|
||||||
codexHome ? path.join(codexHome, 'generated_images') : null,
|
codexHome ? path.join(codexHome, 'generated_images') : null,
|
||||||
@@ -84,7 +83,7 @@ function isWithinDefaultTempAttachmentDir(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDefaultTempAttachmentFile(
|
function isDirectTempAttachmentFile(
|
||||||
realPath: string,
|
realPath: string,
|
||||||
tempDir: string | null,
|
tempDir: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -93,10 +92,10 @@ function isDefaultTempAttachmentFile(
|
|||||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (relative.includes(path.sep)) return false;
|
// Agents commonly write Playwright screenshots and generated images directly
|
||||||
return DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES.some((prefix) =>
|
// under /tmp with task-specific names. Keep this limited to direct children;
|
||||||
relative.startsWith(prefix),
|
// nested temp directories still need an EJClaw prefix or explicit baseDir.
|
||||||
);
|
return !relative.includes(path.sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectImageMime(filePath: string): string | null {
|
function detectImageMime(filePath: string): string | null {
|
||||||
@@ -188,7 +187,7 @@ export function validateOutboundAttachments(
|
|||||||
if (
|
if (
|
||||||
!matchesAllowedBaseDir(realPath, baseDirs) &&
|
!matchesAllowedBaseDir(realPath, baseDirs) &&
|
||||||
!isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) &&
|
!isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) &&
|
||||||
!isDefaultTempAttachmentFile(realPath, defaultTempDir)
|
!isDirectTempAttachmentFile(realPath, defaultTempDir)
|
||||||
) {
|
) {
|
||||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
46
src/web-dashboard-attachments.test.ts
Normal file
46
src/web-dashboard-attachments.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createWebDashboardHandler } from './web-dashboard-server.js';
|
||||||
|
|
||||||
|
const ONE_PIXEL_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempFiles: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const file of tempFiles.splice(0)) {
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('web dashboard attachment previews', () => {
|
||||||
|
it('serves validated direct temp image attachments', async () => {
|
||||||
|
const filePath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||||
|
tempFiles.push(filePath);
|
||||||
|
const handler = createWebDashboardHandler({
|
||||||
|
readStatusSnapshots: () => [],
|
||||||
|
getTasks: () => [],
|
||||||
|
getPairedTasks: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await handler(
|
||||||
|
new Request(
|
||||||
|
`http://localhost/api/attachments?path=${encodeURIComponent(filePath)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toBe('image/png');
|
||||||
|
expect(Buffer.from(await response.arrayBuffer())).toEqual(ONE_PIXEL_PNG);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/web-dashboard-attachments.ts
Normal file
50
src/web-dashboard-attachments.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { validateOutboundAttachments } from './outbound-attachments.js';
|
||||||
|
|
||||||
|
function attachmentJsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
...(init?.headers as Record<string, string> | undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachmentContentType(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === '.png') return 'image/png';
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||||
|
if (ext === '.gif') return 'image/gif';
|
||||||
|
if (ext === '.webp') return 'image/webp';
|
||||||
|
if (ext === '.bmp') return 'image/bmp';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serveValidatedAttachment(url: URL): Response {
|
||||||
|
const requestedPath = url.searchParams.get('path');
|
||||||
|
if (!requestedPath) {
|
||||||
|
return attachmentJsonResponse(
|
||||||
|
{ error: 'Missing attachment path' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateOutboundAttachments([{ path: requestedPath }]);
|
||||||
|
const file = validation.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return attachmentJsonResponse(
|
||||||
|
{ error: 'Attachment not found or not allowed' },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(fs.readFileSync(file.attachment), {
|
||||||
|
headers: {
|
||||||
|
'content-type': getAttachmentContentType(file.attachment),
|
||||||
|
'cache-control': 'private, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
155
src/web-dashboard-data-attachments.test.ts
Normal file
155
src/web-dashboard-data-attachments.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
||||||
|
import { buildWebDashboardRoomActivity } from './web-dashboard-data.js';
|
||||||
|
|
||||||
|
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||||
|
return {
|
||||||
|
id: 'paired-1',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
group_folder: 'ops',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude-reviewer',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: 'codex',
|
||||||
|
title: 'Dashboard PR',
|
||||||
|
source_ref: null,
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
owner_failure_count: 0,
|
||||||
|
owner_step_done_streak: 0,
|
||||||
|
finalize_step_done_count: 0,
|
||||||
|
task_done_then_user_reopen_count: 0,
|
||||||
|
empty_step_done_streak: 0,
|
||||||
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-26T04:00:00.000Z',
|
||||||
|
updated_at: '2026-04-26T04:30:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomEntry = {
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: '#ops',
|
||||||
|
folder: 'ops',
|
||||||
|
agentType: 'codex' as const,
|
||||||
|
status: 'inactive' as const,
|
||||||
|
elapsedMs: null,
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('web dashboard attachment data', () => {
|
||||||
|
it('normalizes structured EJClaw envelopes in room messages and outputs', () => {
|
||||||
|
const task = makePairedTask({ id: 'paired-structured' });
|
||||||
|
const structured = JSON.stringify({
|
||||||
|
ejclaw: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const output: PairedTurnOutput = {
|
||||||
|
id: 1,
|
||||||
|
task_id: task.id,
|
||||||
|
turn_number: 1,
|
||||||
|
role: 'owner',
|
||||||
|
output_text: structured,
|
||||||
|
verdict: 'done',
|
||||||
|
created_at: '2026-04-26T05:30:00.000Z',
|
||||||
|
};
|
||||||
|
const message: NewMessage = {
|
||||||
|
id: 'msg-structured',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
sender: 'bot-1',
|
||||||
|
sender_name: 'owner',
|
||||||
|
content: structured,
|
||||||
|
timestamp: '2026-04-26T05:31:00.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
is_bot_message: true,
|
||||||
|
message_source_kind: 'bot',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activity = buildWebDashboardRoomActivity({
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
entry: roomEntry,
|
||||||
|
pairedTask: task,
|
||||||
|
turns: [],
|
||||||
|
attempts: [],
|
||||||
|
outputs: [output],
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.messages[0]).toMatchObject({
|
||||||
|
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(activity.messages[0]?.content).not.toContain('"ejclaw"');
|
||||||
|
expect(activity.pairedTask?.outputs[0]).toMatchObject({
|
||||||
|
outputText: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(activity.pairedTask?.outputs[0]?.outputText).not.toContain(
|
||||||
|
'"ejclaw"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns legacy Discord image placeholders into dashboard attachments', () => {
|
||||||
|
const message: NewMessage = {
|
||||||
|
id: 'msg-image',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
sender: 'bot-1',
|
||||||
|
sender_name: 'owner',
|
||||||
|
content:
|
||||||
|
'라벨 좌측 클리핑 회귀 수정했습니다.\n[Image: screenshot.png → /tmp/bar-chart-label-fit-playwright.png]',
|
||||||
|
timestamp: '2026-04-26T05:31:00.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
is_bot_message: true,
|
||||||
|
message_source_kind: 'bot',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activity = buildWebDashboardRoomActivity({
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
entry: roomEntry,
|
||||||
|
pairedTask: null,
|
||||||
|
turns: [],
|
||||||
|
attempts: [],
|
||||||
|
outputs: [],
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.messages[0]).toMatchObject({
|
||||||
|
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { normalizeEjclawStructuredOutput } from './agent-protocol.js';
|
import {
|
||||||
|
extractImageTagPaths,
|
||||||
|
normalizeEjclawStructuredOutput,
|
||||||
|
} from './agent-protocol.js';
|
||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||||
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
||||||
import type {
|
import type {
|
||||||
NewMessage,
|
NewMessage,
|
||||||
|
OutboundAttachment,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
PairedTurnOutput,
|
PairedTurnOutput,
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
@@ -69,6 +73,7 @@ export interface WebDashboardRoomMessage {
|
|||||||
sender: string;
|
sender: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments: OutboundAttachment[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
isFromMe: boolean;
|
isFromMe: boolean;
|
||||||
isBotMessage: boolean;
|
isBotMessage: boolean;
|
||||||
@@ -99,6 +104,7 @@ export interface WebDashboardRoomTurnOutput {
|
|||||||
verdict: PairedTurnOutput['verdict'] | null;
|
verdict: PairedTurnOutput['verdict'] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
outputText: string;
|
outputText: string;
|
||||||
|
attachments: OutboundAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebDashboardRoomActivity {
|
export interface WebDashboardRoomActivity {
|
||||||
@@ -196,22 +202,54 @@ function hasStructuredOutputHint(value: string): boolean {
|
|||||||
return value.includes('"ejclaw"') || /"ejclaw"/i.test(value);
|
return value.includes('"ejclaw"') || /"ejclaw"/i.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRoomMessageContent(value: string): string | null {
|
function imagePathsToAttachments(paths: string[]): OutboundAttachment[] {
|
||||||
if (!hasStructuredOutputHint(value)) return value;
|
return paths.map((filePath) => ({
|
||||||
|
path: filePath,
|
||||||
|
name: filePath.split(/[\\/]/).at(-1) || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLegacyImageTags(
|
||||||
|
text: string,
|
||||||
|
attachments: OutboundAttachment[] = [],
|
||||||
|
): { text: string; attachments: OutboundAttachment[] } {
|
||||||
|
const extracted = extractImageTagPaths(text);
|
||||||
|
if (extracted.imagePaths.length === 0) {
|
||||||
|
return { text, attachments };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: extracted.cleanText,
|
||||||
|
attachments: [
|
||||||
|
...attachments,
|
||||||
|
...imagePathsToAttachments(extracted.imagePaths),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStructuredVisibleContent(value: string): {
|
||||||
|
text: string | null;
|
||||||
|
attachments: OutboundAttachment[];
|
||||||
|
} {
|
||||||
|
if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(value);
|
||||||
|
|
||||||
const candidates = [value, decodeCommonHtmlEntities(value)];
|
const candidates = [value, decodeCommonHtmlEntities(value)];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const normalized = normalizeEjclawStructuredOutput(candidate);
|
const normalized = normalizeEjclawStructuredOutput(candidate);
|
||||||
if (normalized.output?.visibility === 'silent') return null;
|
if (normalized.output?.visibility === 'silent') {
|
||||||
|
return { text: null, attachments: [] };
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
normalized.output?.visibility === 'public' &&
|
normalized.output?.visibility === 'public' &&
|
||||||
normalized.output.text !== candidate
|
normalized.output.text !== candidate
|
||||||
) {
|
) {
|
||||||
return normalized.output.text;
|
return splitLegacyImageTags(
|
||||||
|
normalized.output.text,
|
||||||
|
normalized.output.attachments ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return splitLegacyImageTags(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableHash(value: string): string {
|
function stableHash(value: string): string {
|
||||||
@@ -440,14 +478,15 @@ export function sanitizeScheduledTask(
|
|||||||
function sanitizeRoomMessage(
|
function sanitizeRoomMessage(
|
||||||
message: NewMessage,
|
message: NewMessage,
|
||||||
): WebDashboardRoomMessage | null {
|
): WebDashboardRoomMessage | null {
|
||||||
const content = normalizeRoomMessageContent(message.content ?? '');
|
const normalized = normalizeStructuredVisibleContent(message.content ?? '');
|
||||||
if (content === null) return null;
|
if (normalized.text === null) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
senderName: message.sender_name || message.sender,
|
senderName: message.sender_name || message.sender,
|
||||||
content: buildRoomPreview(content, ROOM_MESSAGE_PREVIEW_MAX_LENGTH),
|
content: buildRoomPreview(normalized.text, ROOM_MESSAGE_PREVIEW_MAX_LENGTH),
|
||||||
|
attachments: normalized.attachments,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
isFromMe: !!message.is_from_me,
|
isFromMe: !!message.is_from_me,
|
||||||
isBotMessage: !!message.is_bot_message,
|
isBotMessage: !!message.is_bot_message,
|
||||||
@@ -549,7 +588,10 @@ function sanitizeRoomTurn(
|
|||||||
|
|
||||||
function sanitizeRoomTurnOutput(
|
function sanitizeRoomTurnOutput(
|
||||||
output: PairedTurnOutput,
|
output: PairedTurnOutput,
|
||||||
): WebDashboardRoomTurnOutput {
|
): WebDashboardRoomTurnOutput | null {
|
||||||
|
const normalized = normalizeStructuredVisibleContent(output.output_text);
|
||||||
|
if (normalized.text === null) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: output.id,
|
id: output.id,
|
||||||
turnNumber: output.turn_number,
|
turnNumber: output.turn_number,
|
||||||
@@ -557,9 +599,10 @@ function sanitizeRoomTurnOutput(
|
|||||||
verdict: output.verdict ?? null,
|
verdict: output.verdict ?? null,
|
||||||
createdAt: output.created_at,
|
createdAt: output.created_at,
|
||||||
outputText: buildRoomPreview(
|
outputText: buildRoomPreview(
|
||||||
output.output_text,
|
normalized.text,
|
||||||
ROOM_OUTPUT_PREVIEW_MAX_LENGTH,
|
ROOM_OUTPUT_PREVIEW_MAX_LENGTH,
|
||||||
),
|
),
|
||||||
|
attachments: normalized.attachments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +663,12 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
args.progressMessages ?? args.messages,
|
args.progressMessages ?? args.messages,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput),
|
outputs: args.outputs
|
||||||
|
.slice(-outputLimit)
|
||||||
|
.map(sanitizeRoomTurnOutput)
|
||||||
|
.filter((output): output is WebDashboardRoomTurnOutput =>
|
||||||
|
Boolean(output),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
buildWebDashboardOverview,
|
buildWebDashboardOverview,
|
||||||
sanitizeScheduledTask,
|
sanitizeScheduledTask,
|
||||||
} from './web-dashboard-data.js';
|
} from './web-dashboard-data.js';
|
||||||
|
import { serveValidatedAttachment } from './web-dashboard-attachments.js';
|
||||||
import {
|
import {
|
||||||
addClaudeAccountFromToken,
|
addClaudeAccountFromToken,
|
||||||
getActiveCodexSettingsIndex,
|
getActiveCodexSettingsIndex,
|
||||||
@@ -1570,9 +1571,15 @@ export function createWebDashboardHandler(
|
|||||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/health') {
|
const simpleGetRoutes: Record<string, () => Response> = {
|
||||||
return jsonResponse({ ok: true });
|
'/api/health': () => jsonResponse({ ok: true }),
|
||||||
}
|
'/api/status-snapshots': () =>
|
||||||
|
jsonResponse(readSnapshots(statusMaxAgeMs)),
|
||||||
|
'/api/tasks': () => jsonResponse(loadTasks().map(sanitizeScheduledTask)),
|
||||||
|
'/api/attachments': () => serveValidatedAttachment(url),
|
||||||
|
};
|
||||||
|
const simpleGetRoute = simpleGetRoutes[url.pathname];
|
||||||
|
if (simpleGetRoute) return simpleGetRoute();
|
||||||
|
|
||||||
if (url.pathname === '/api/overview') {
|
if (url.pathname === '/api/overview') {
|
||||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||||
@@ -1593,14 +1600,6 @@ export function createWebDashboardHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/status-snapshots') {
|
|
||||||
return jsonResponse(readSnapshots(statusMaxAgeMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/api/tasks') {
|
|
||||||
return jsonResponse(loadTasks().map(sanitizeScheduledTask));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/api/settings/accounts') {
|
if (url.pathname === '/api/settings/accounts') {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
claude: listClaudeAccounts(),
|
claude: listClaudeAccounts(),
|
||||||
|
|||||||
Reference in New Issue
Block a user