Add structured Discord attachments
This commit is contained in:
@@ -4,3 +4,25 @@ You have a `send_message` tool that sends a message immediately while you are st
|
|||||||
Use it to acknowledge a request before starting longer work.
|
Use it to acknowledge a request before starting longer work.
|
||||||
|
|
||||||
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
|
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
|
||||||
|
|
||||||
|
## Image attachments
|
||||||
|
|
||||||
|
When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ejclaw": {
|
||||||
|
"visibility": "public",
|
||||||
|
"text": "스크린샷을 첨부했습니다.",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"path": "/absolute/path/screenshot.png",
|
||||||
|
"name": "screenshot.png",
|
||||||
|
"mime": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||||
|
|||||||
@@ -28,9 +28,36 @@ Your output is sent directly to the Discord group.
|
|||||||
- Do not use generic recurring task registration from Codex
|
- Do not use generic recurring task registration from Codex
|
||||||
- If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it
|
- If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it
|
||||||
|
|
||||||
|
## Image attachments
|
||||||
|
|
||||||
|
When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ejclaw": {
|
||||||
|
"visibility": "public",
|
||||||
|
"text": "이미지를 생성했습니다.",
|
||||||
|
"verdict": "done",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"path": "/absolute/path/image.png",
|
||||||
|
"name": "image.png",
|
||||||
|
"mime": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `attachments.path` must be an absolute local path; URLs and relative paths are ignored
|
||||||
|
- Do not repeat the same file path in the visible text
|
||||||
|
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||||
|
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file
|
||||||
|
|
||||||
## CI 감시 (watch_ci)
|
## CI 감시 (watch_ci)
|
||||||
|
|
||||||
GitHub Actions run 감시는 structured 필드를 우선 사용:
|
GitHub Actions run 감시는 structured 필드를 우선 사용:
|
||||||
|
|
||||||
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
|
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
|
||||||
- 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling)
|
- 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling)
|
||||||
- structured 필드 없이 generic 등록 시 매 tick LLM 실행됨
|
- structured 필드 없이 generic 등록 시 매 tick LLM 실행됨
|
||||||
|
|||||||
@@ -20,9 +20,36 @@ Do not use markdown headings in chat replies. Keep messages clean and readable f
|
|||||||
|
|
||||||
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
|
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
|
||||||
|
|
||||||
|
## Image attachments
|
||||||
|
|
||||||
|
For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ejclaw": {
|
||||||
|
"visibility": "public",
|
||||||
|
"text": "스크린샷을 첨부했습니다.",
|
||||||
|
"verdict": "done",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"path": "/absolute/path/screenshot.png",
|
||||||
|
"name": "screenshot.png",
|
||||||
|
"mime": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use absolute local paths only
|
||||||
|
- Do not duplicate the same path in the visible text
|
||||||
|
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||||
|
- The channel harness validates and uploads attachments; plain prose paths are not reliable
|
||||||
|
|
||||||
## CI monitoring (watch_ci)
|
## CI monitoring (watch_ci)
|
||||||
|
|
||||||
GitHub Actions run monitoring uses structured fields first:
|
GitHub Actions run monitoring uses structured fields first:
|
||||||
|
|
||||||
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
|
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
|
||||||
- This combination → host-driven fast path (no LLM token cost, 15s polling)
|
- This combination → host-driven fast path (no LLM token cost, 15s polling)
|
||||||
- Without structured fields → generic path, each tick runs LLM
|
- Without structured fields → generic path, each tick runs LLM
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
extractImageTagPaths,
|
extractImageTagPaths,
|
||||||
normalizePublicTextOutput,
|
normalizeEjclawStructuredOutput,
|
||||||
type RunnerStructuredOutput,
|
type RunnerStructuredOutput,
|
||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
} from 'ejclaw-runners-shared';
|
} from 'ejclaw-runners-shared';
|
||||||
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
|
|||||||
result: string | null;
|
result: string | null;
|
||||||
output?: RunnerOutput['output'];
|
output?: RunnerOutput['output'];
|
||||||
} {
|
} {
|
||||||
return normalizePublicTextOutput(result);
|
return normalizeEjclawStructuredOutput(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractAssistantText(message: unknown): string | null {
|
export function extractAssistantText(message: unknown): string | null {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
|
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
|
||||||
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
||||||
|
|
||||||
export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
export const IMAGE_TAG_RE =
|
||||||
|
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
|
||||||
|
|
||||||
export const IPC_POLL_MS = 500;
|
export const IPC_POLL_MS = 500;
|
||||||
export const IPC_INPUT_SUBDIR = 'input';
|
export const IPC_INPUT_SUBDIR = 'input';
|
||||||
@@ -21,11 +22,18 @@ export type RunnerOutputVerdict =
|
|||||||
|
|
||||||
export type RunnerOutputVisibility = 'public' | 'silent';
|
export type RunnerOutputVisibility = 'public' | 'silent';
|
||||||
|
|
||||||
|
export interface RunnerOutputAttachment {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
mime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type RunnerStructuredOutput =
|
export type RunnerStructuredOutput =
|
||||||
| {
|
| {
|
||||||
visibility: 'public';
|
visibility: 'public';
|
||||||
text: string;
|
text: string;
|
||||||
verdict?: Exclude<RunnerOutputVerdict, 'silent'>;
|
verdict?: Exclude<RunnerOutputVerdict, 'silent'>;
|
||||||
|
attachments?: RunnerOutputAttachment[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
visibility: 'silent';
|
visibility: 'silent';
|
||||||
@@ -89,6 +97,33 @@ function isVisibleVerdict(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAttachments(value: unknown): RunnerOutputAttachment[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
const attachments: RunnerOutputAttachment[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||||
|
const candidate = item as {
|
||||||
|
path?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
mime?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof candidate.path !== 'string' || candidate.path.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attachments.push({
|
||||||
|
path: candidate.path,
|
||||||
|
...(typeof candidate.name === 'string' && candidate.name.length > 0
|
||||||
|
? { name: candidate.name }
|
||||||
|
: {}),
|
||||||
|
...(typeof candidate.mime === 'string' && candidate.mime.length > 0
|
||||||
|
? { mime: candidate.mime }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeEjclawStructuredOutput(
|
export function normalizeEjclawStructuredOutput(
|
||||||
result: string | null,
|
result: string | null,
|
||||||
): NormalizedRunnerOutput {
|
): NormalizedRunnerOutput {
|
||||||
@@ -99,7 +134,12 @@ export function normalizeEjclawStructuredOutput(
|
|||||||
const trimmed = result.trim();
|
const trimmed = result.trim();
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed) as {
|
const parsed = JSON.parse(trimmed) as {
|
||||||
ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
|
ejclaw?: {
|
||||||
|
visibility?: unknown;
|
||||||
|
text?: unknown;
|
||||||
|
verdict?: unknown;
|
||||||
|
attachments?: unknown;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const envelope = parsed?.ejclaw;
|
const envelope = parsed?.ejclaw;
|
||||||
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
||||||
@@ -128,6 +168,7 @@ export function normalizeEjclawStructuredOutput(
|
|||||||
) {
|
) {
|
||||||
return normalizePublicTextOutput(result);
|
return normalizePublicTextOutput(result);
|
||||||
}
|
}
|
||||||
|
const attachments = normalizeAttachments(envelope.attachments);
|
||||||
return {
|
return {
|
||||||
result: envelope.text,
|
result: envelope.text,
|
||||||
output: {
|
output: {
|
||||||
@@ -136,6 +177,7 @@ export function normalizeEjclawStructuredOutput(
|
|||||||
verdict: isVisibleVerdict(envelope.verdict)
|
verdict: isVisibleVerdict(envelope.verdict)
|
||||||
? envelope.verdict
|
? envelope.verdict
|
||||||
: undefined,
|
: undefined,
|
||||||
|
...(attachments.length > 0 ? { attachments } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
type NormalizedRunnerOutput,
|
type NormalizedRunnerOutput,
|
||||||
type RunnerOutputPhase,
|
type RunnerOutputPhase,
|
||||||
|
type RunnerOutputAttachment,
|
||||||
type RunnerOutputVerdict,
|
type RunnerOutputVerdict,
|
||||||
type RunnerOutputVisibility,
|
type RunnerOutputVisibility,
|
||||||
type RunnerStructuredOutput,
|
type RunnerStructuredOutput,
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ describe('shared agent protocol helpers', () => {
|
|||||||
cleanText: 'hello',
|
cleanText: 'hello',
|
||||||
imagePaths: ['/tmp/a.png'],
|
imagePaths: ['/tmp/a.png'],
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
extractImageTagPaths('hello [Image: screenshot.png → /tmp/a.png]'),
|
||||||
|
).toEqual({
|
||||||
|
cleanText: 'hello',
|
||||||
|
imagePaths: ['/tmp/a.png'],
|
||||||
|
});
|
||||||
expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({
|
expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({
|
||||||
cleanText: 'second',
|
cleanText: 'second',
|
||||||
imagePaths: ['/tmp/b.png'],
|
imagePaths: ['/tmp/b.png'],
|
||||||
@@ -45,6 +51,41 @@ describe('shared agent protocol helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses public ejclaw attachments', () => {
|
||||||
|
expect(
|
||||||
|
normalizeEjclawStructuredOutput(
|
||||||
|
JSON.stringify({
|
||||||
|
ejclaw: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '이미지를 생성했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/image.png',
|
||||||
|
name: 'image.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
result: '이미지를 생성했습니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '이미지를 생성했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/image.png',
|
||||||
|
name: 'image.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to visible raw text on invalid public verdicts', () => {
|
it('falls back to visible raw text on invalid public verdicts', () => {
|
||||||
const raw = JSON.stringify({
|
const raw = JSON.stringify({
|
||||||
ejclaw: {
|
ejclaw: {
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export function getAgentOutputText(output: {
|
|||||||
return stringifyLegacyAgentResult(output.result);
|
return stringifyLegacyAgentResult(output.result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAgentOutputAttachments(output: {
|
||||||
|
output?: StructuredAgentOutput;
|
||||||
|
}): NonNullable<
|
||||||
|
Extract<StructuredAgentOutput, { visibility: 'public' }>['attachments']
|
||||||
|
> {
|
||||||
|
const structured = getStructuredAgentOutput(output);
|
||||||
|
if (structured?.visibility !== 'public') return [];
|
||||||
|
return structured.attachments ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export function hasAgentOutputPayload(output: {
|
export function hasAgentOutputPayload(output: {
|
||||||
output?: StructuredAgentOutput;
|
output?: StructuredAgentOutput;
|
||||||
result?: string | object | null;
|
result?: string | object | null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export {
|
|||||||
OUTPUT_START_MARKER,
|
OUTPUT_START_MARKER,
|
||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
type NormalizedRunnerOutput,
|
type NormalizedRunnerOutput,
|
||||||
|
type RunnerOutputAttachment,
|
||||||
type RunnerOutputPhase,
|
type RunnerOutputPhase,
|
||||||
type RunnerOutputVerdict,
|
type RunnerOutputVerdict,
|
||||||
type RunnerOutputVisibility,
|
type RunnerOutputVisibility,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
@@ -127,6 +131,18 @@ import { logger } from '../logger.js';
|
|||||||
|
|
||||||
// --- Test helpers ---
|
// --- Test helpers ---
|
||||||
|
|
||||||
|
const ONE_PIXEL_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
function createTempPng(name = 'image.png'): { dir: string; filePath: string } {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-'));
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||||
|
return { dir, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
function createTestOpts(
|
function createTestOpts(
|
||||||
overrides?: Partial<DiscordChannelOpts>,
|
overrides?: Partial<DiscordChannelOpts>,
|
||||||
): DiscordChannelOpts {
|
): DiscordChannelOpts {
|
||||||
@@ -839,6 +855,59 @@ describe('DiscordChannel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends structured attachments as Discord files', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
const { dir, filePath } = createTempPng('structured.png');
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
await channel.sendMessage(
|
||||||
|
'dc:1234567890123456',
|
||||||
|
'이미지를 생성했습니다.',
|
||||||
|
{
|
||||||
|
attachments: [
|
||||||
|
{ path: filePath, name: 'result.png', mime: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||||
|
content: '이미지를 생성했습니다.',
|
||||||
|
files: [{ attachment: filePath, name: 'result.png' }],
|
||||||
|
flags: 1 << 2,
|
||||||
|
});
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses legacy image tags as Discord attachment fallback', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
const { dir, filePath } = createTempPng('screenshot.png');
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
await channel.sendMessage(
|
||||||
|
'dc:1234567890123456',
|
||||||
|
`스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||||
|
content: '스크린샷입니다.',
|
||||||
|
files: [{ attachment: filePath, name: 'screenshot.png' }],
|
||||||
|
flags: 1 << 2,
|
||||||
|
});
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('logs channel name and Discord message ids after sending', async () => {
|
it('logs channel name and Discord message ids after sending', async () => {
|
||||||
const opts = createTestOpts();
|
const opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
|
|||||||
@@ -19,8 +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 { 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';
|
||||||
|
|
||||||
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');
|
||||||
@@ -30,6 +33,45 @@ 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.
|
||||||
@@ -403,7 +445,11 @@ export class DiscordChannel implements Channel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(jid: string, text: string): Promise<void> {
|
async sendMessage(
|
||||||
|
jid: string,
|
||||||
|
text: string,
|
||||||
|
options: SendMessageOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
logger.warn('Discord client not initialized');
|
logger.warn('Discord client not initialized');
|
||||||
return;
|
return;
|
||||||
@@ -420,37 +466,43 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
const textChannel = channel as TextChannel;
|
const textChannel = channel as TextChannel;
|
||||||
|
|
||||||
// Extract image attachments from markdown links with image extensions
|
const structuredAttachments = options.attachments ?? [];
|
||||||
// e.g. [name.png](/absolute/path/name.png)
|
const hasStructuredAttachments = structuredAttachments.length > 0;
|
||||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|bmp)$/i;
|
const markdownExtracted = extractMarkdownImageAttachments(text);
|
||||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
const imageTagExtracted = extractImageTagPaths(
|
||||||
const imageFiles: string[] = [];
|
markdownExtracted.cleanText,
|
||||||
const seen = new Set<string>();
|
);
|
||||||
let match;
|
const legacyImageTagAttachments = imageTagPathsToAttachments(
|
||||||
|
imageTagExtracted.imagePaths,
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const files = validation.files;
|
||||||
|
|
||||||
while ((match = MD_LINK_RE.exec(text)) !== null) {
|
if (validation.rejected.length > 0) {
|
||||||
const imgPath = match[1].trim();
|
logger.warn(
|
||||||
if (
|
{
|
||||||
!seen.has(imgPath) &&
|
jid,
|
||||||
IMAGE_EXTS.test(imgPath) &&
|
channelName: this.name,
|
||||||
fs.existsSync(imgPath)
|
attachmentSource,
|
||||||
) {
|
rejected: validation.rejected,
|
||||||
imageFiles.push(imgPath);
|
},
|
||||||
seen.add(imgPath);
|
'Rejected outbound Discord attachments',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let cleaned = text
|
let cleaned = imageTagExtracted.cleanText
|
||||||
.replace(MD_LINK_RE, (full, p, _offset, _str, groups) => {
|
|
||||||
const trimmed = p.trim();
|
|
||||||
// Image links: remove entirely (attached as files)
|
|
||||||
if (IMAGE_EXTS.test(trimmed) && seen.has(trimmed)) return '';
|
|
||||||
// Non-image local path links: convert to readable filename
|
|
||||||
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
|
||||||
const lineMatch = trimmed.match(/#L(\d+)/);
|
|
||||||
return lineMatch
|
|
||||||
? `\`${basename}:${lineMatch[1]}\``
|
|
||||||
: `\`${basename}\``;
|
|
||||||
})
|
|
||||||
.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();
|
||||||
@@ -467,10 +519,6 @@ export class DiscordChannel implements Channel {
|
|||||||
// Discord has a 2000 character limit per message and 10 attachments per message
|
// Discord has a 2000 character limit per message and 10 attachments per message
|
||||||
const MAX_LENGTH = 2000;
|
const MAX_LENGTH = 2000;
|
||||||
const MAX_ATTACHMENTS = 10;
|
const MAX_ATTACHMENTS = 10;
|
||||||
const files = imageFiles.map((f) => ({
|
|
||||||
attachment: f,
|
|
||||||
name: path.basename(f),
|
|
||||||
}));
|
|
||||||
const sentMessageIds: string[] = [];
|
const sentMessageIds: string[] = [];
|
||||||
let chunkCount = 0;
|
let chunkCount = 0;
|
||||||
|
|
||||||
@@ -544,6 +592,7 @@ export class DiscordChannel implements Channel {
|
|||||||
deliveryMode: 'send',
|
deliveryMode: 'send',
|
||||||
chunkCount,
|
chunkCount,
|
||||||
attachmentCount: files.length,
|
attachmentCount: files.length,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -9106,6 +9106,38 @@ describe('work items', () => {
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores produced work item attachments for delivery retries', () => {
|
||||||
|
const item = createProducedWorkItem({
|
||||||
|
group_folder: 'discord_test',
|
||||||
|
chat_jid: 'dc:attachments',
|
||||||
|
agent_type: 'claude-code',
|
||||||
|
delivery_role: 'owner',
|
||||||
|
start_seq: 1,
|
||||||
|
end_seq: 2,
|
||||||
|
result_payload: 'image ready',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/image.png',
|
||||||
|
name: 'image.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = getOpenWorkItem(
|
||||||
|
'dc:attachments',
|
||||||
|
'claude-code',
|
||||||
|
item.service_id,
|
||||||
|
);
|
||||||
|
expect(stored?.attachments).toEqual([
|
||||||
|
{
|
||||||
|
path: '/tmp/image.png',
|
||||||
|
name: 'image.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('finds pending delivery retries even when they were created by a fallback agent type', () => {
|
it('finds pending delivery retries even when they were created by a fallback agent type', () => {
|
||||||
const fallbackItem = createProducedWorkItem({
|
const fallbackItem = createProducedWorkItem({
|
||||||
group_folder: 'discord_test',
|
group_folder: 'discord_test',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function applyBaseSchema(database: Database): void {
|
|||||||
start_seq INTEGER,
|
start_seq INTEGER,
|
||||||
end_seq INTEGER,
|
end_seq INTEGER,
|
||||||
result_payload TEXT NOT NULL,
|
result_payload TEXT NOT NULL,
|
||||||
|
attachment_payload TEXT,
|
||||||
delivery_attempts INTEGER NOT NULL DEFAULT 0,
|
delivery_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
delivery_message_id TEXT,
|
delivery_message_id TEXT,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function getExpectedSchemaMigrations(): Array<{
|
|||||||
{ version: 11, name: 'owner_failure_count' },
|
{ version: 11, name: 'owner_failure_count' },
|
||||||
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
||||||
{ version: 13, name: 'message_source_kind' },
|
{ version: 13, name: 'message_source_kind' },
|
||||||
|
{ version: 14, name: 'work_item_attachments' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/db/migrations/014_work-item-attachments.ts
Normal file
17
src/db/migrations/014_work-item-attachments.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
|
import { tableHasColumn } from './helpers.js';
|
||||||
|
import type { SchemaMigrationDefinition } from './types.js';
|
||||||
|
|
||||||
|
export const WORK_ITEM_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition = {
|
||||||
|
version: 14,
|
||||||
|
name: 'work_item_attachments',
|
||||||
|
apply(database: Database) {
|
||||||
|
if (!tableHasColumn(database, 'work_items', 'attachment_payload')) {
|
||||||
|
database.exec(`
|
||||||
|
ALTER TABLE work_items
|
||||||
|
ADD COLUMN attachment_payload TEXT
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-prov
|
|||||||
import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
|
import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
|
||||||
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
|
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
|
||||||
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
|
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
|
||||||
|
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
|
||||||
import type {
|
import type {
|
||||||
SchemaMigrationArgs,
|
SchemaMigrationArgs,
|
||||||
SchemaMigrationDefinition,
|
SchemaMigrationDefinition,
|
||||||
@@ -34,6 +35,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
|||||||
OWNER_FAILURE_COUNT_MIGRATION,
|
OWNER_FAILURE_COUNT_MIGRATION,
|
||||||
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
||||||
MESSAGE_SOURCE_KIND_MIGRATION,
|
MESSAGE_SOURCE_KIND_MIGRATION,
|
||||||
|
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
||||||
];
|
];
|
||||||
|
|
||||||
function ensureSchemaMigrationsTable(database: Database): void {
|
function ensureSchemaMigrationsTable(database: Database): void {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
inferRoleFromServiceShadow,
|
inferRoleFromServiceShadow,
|
||||||
resolveRoleServiceShadow,
|
resolveRoleServiceShadow,
|
||||||
} from '../role-service-shadow.js';
|
} from '../role-service-shadow.js';
|
||||||
import { AgentType, PairedRoomRole } from '../types.js';
|
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
|
||||||
|
|
||||||
export interface WorkItem {
|
export interface WorkItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -19,6 +19,7 @@ export interface WorkItem {
|
|||||||
start_seq: number | null;
|
start_seq: number | null;
|
||||||
end_seq: number | null;
|
end_seq: number | null;
|
||||||
result_payload: string;
|
result_payload: string;
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
delivery_attempts: number;
|
delivery_attempts: number;
|
||||||
delivery_message_id: string | null;
|
delivery_message_id: string | null;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
@@ -29,10 +30,11 @@ export interface WorkItem {
|
|||||||
|
|
||||||
interface StoredWorkItemRow extends Omit<
|
interface StoredWorkItemRow extends Omit<
|
||||||
WorkItem,
|
WorkItem,
|
||||||
'agent_type' | 'service_id'
|
'agent_type' | 'service_id' | 'attachments'
|
||||||
> {
|
> {
|
||||||
agent_type: string;
|
agent_type: string;
|
||||||
service_id?: string | null;
|
service_id?: string | null;
|
||||||
|
attachment_payload?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProducedWorkItemInput {
|
export interface CreateProducedWorkItemInput {
|
||||||
@@ -44,6 +46,7 @@ export interface CreateProducedWorkItemInput {
|
|||||||
start_seq: number | null;
|
start_seq: number | null;
|
||||||
end_seq: number | null;
|
end_seq: number | null;
|
||||||
result_payload: string;
|
result_payload: string;
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredAgentType(
|
function normalizeStoredAgentType(
|
||||||
@@ -96,9 +99,48 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
|
|||||||
...row,
|
...row,
|
||||||
agent_type: agentType,
|
agent_type: agentType,
|
||||||
service_id: readStoredWorkItemServiceId(row),
|
service_id: readStoredWorkItemServiceId(row),
|
||||||
|
attachments: parseAttachmentPayload(row.attachment_payload),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAttachmentPayload(
|
||||||
|
payload: string | null | undefined,
|
||||||
|
): OutboundAttachment[] {
|
||||||
|
if (!payload) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(item): item is OutboundAttachment =>
|
||||||
|
item !== null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
!Array.isArray(item) &&
|
||||||
|
typeof (item as { path?: unknown }).path === 'string',
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
...(typeof item.name === 'string' ? { name: item.name } : {}),
|
||||||
|
...(typeof item.mime === 'string' ? { mime: item.mime } : {}),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAttachmentPayload(
|
||||||
|
attachments: OutboundAttachment[] | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!attachments?.length) return null;
|
||||||
|
return JSON.stringify(
|
||||||
|
attachments.map((attachment) => ({
|
||||||
|
path: attachment.path,
|
||||||
|
...(attachment.name ? { name: attachment.name } : {}),
|
||||||
|
...(attachment.mime ? { mime: attachment.mime } : {}),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePreferredWorkItemRole(
|
function resolvePreferredWorkItemRole(
|
||||||
serviceId: string | null | undefined,
|
serviceId: string | null | undefined,
|
||||||
): PairedRoomRole | null {
|
): PairedRoomRole | null {
|
||||||
@@ -220,10 +262,11 @@ export function createProducedWorkItemInDatabase(
|
|||||||
start_seq,
|
start_seq,
|
||||||
end_seq,
|
end_seq,
|
||||||
result_payload,
|
result_payload,
|
||||||
|
attachment_payload,
|
||||||
delivery_attempts,
|
delivery_attempts,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
input.group_folder,
|
input.group_folder,
|
||||||
@@ -234,6 +277,7 @@ export function createProducedWorkItemInDatabase(
|
|||||||
input.start_seq,
|
input.start_seq,
|
||||||
input.end_seq,
|
input.end_seq,
|
||||||
input.result_payload,
|
input.result_payload,
|
||||||
|
serializeAttachmentPayload(input.attachments),
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ export async function deliverOpenWorkItem(args: {
|
|||||||
channel: Channel;
|
channel: Channel;
|
||||||
item: WorkItem;
|
item: WorkItem;
|
||||||
log: RuntimeDeliveryLog;
|
log: RuntimeDeliveryLog;
|
||||||
|
attachmentBaseDirs?: string[];
|
||||||
replaceMessageId?: string | null;
|
replaceMessageId?: string | null;
|
||||||
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
||||||
openContinuation: (chatJid: string) => void;
|
openContinuation: (chatJid: string) => void;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const replaceMessageId = args.replaceMessageId ?? null;
|
const replaceMessageId = args.replaceMessageId ?? null;
|
||||||
|
const attachments = args.item.attachments ?? [];
|
||||||
|
const hasAttachments = attachments.length > 0;
|
||||||
|
|
||||||
const isDuplicate = args.isDuplicateOfLastBotFinal(
|
const isDuplicate = args.isDuplicateOfLastBotFinal(
|
||||||
args.item.chat_jid,
|
args.item.chat_jid,
|
||||||
@@ -52,7 +55,7 @@ export async function deliverOpenWorkItem(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (replaceMessageId && args.channel.editMessage) {
|
if (replaceMessageId && args.channel.editMessage && !hasAttachments) {
|
||||||
args.log.info(
|
args.log.info(
|
||||||
buildDeliveryLogContext(args.channel, args.item, {
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
@@ -93,19 +96,32 @@ export async function deliverOpenWorkItem(args: {
|
|||||||
try {
|
try {
|
||||||
args.log.info(
|
args.log.info(
|
||||||
buildDeliveryLogContext(args.channel, args.item, {
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
attachmentCount: attachments.length,
|
||||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
deliveryMode: 'send',
|
deliveryMode: 'send',
|
||||||
}),
|
}),
|
||||||
'Attempting to deliver produced work item as a new message',
|
'Attempting to deliver produced work item as a new message',
|
||||||
);
|
);
|
||||||
|
if (hasAttachments) {
|
||||||
|
await args.channel.sendMessage(
|
||||||
|
args.item.chat_jid,
|
||||||
|
args.item.result_payload,
|
||||||
|
{
|
||||||
|
attachmentBaseDirs: args.attachmentBaseDirs,
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await args.channel.sendMessage(
|
await args.channel.sendMessage(
|
||||||
args.item.chat_jid,
|
args.item.chat_jid,
|
||||||
args.item.result_payload,
|
args.item.result_payload,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
markWorkItemDelivered(args.item.id);
|
markWorkItemDelivered(args.item.id);
|
||||||
args.openContinuation(args.item.chat_jid);
|
args.openContinuation(args.item.chat_jid);
|
||||||
args.log.info(
|
args.log.info(
|
||||||
buildDeliveryLogContext(args.channel, args.item, {
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
attachmentCount: attachments.length,
|
||||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
deliveryMode: 'send',
|
deliveryMode: 'send',
|
||||||
}),
|
}),
|
||||||
@@ -117,6 +133,7 @@ export async function deliverOpenWorkItem(args: {
|
|||||||
markWorkItemDeliveryRetry(args.item.id, errorMessage);
|
markWorkItemDeliveryRetry(args.item.id, errorMessage);
|
||||||
args.log.warn(
|
args.log.warn(
|
||||||
buildDeliveryLogContext(args.channel, args.item, {
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
attachmentCount: attachments.length,
|
||||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
deliveryMode: 'send',
|
deliveryMode: 'send',
|
||||||
err,
|
err,
|
||||||
@@ -135,6 +152,7 @@ export async function processOpenWorkItemDelivery(args: {
|
|||||||
channel: Channel;
|
channel: Channel;
|
||||||
roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>;
|
roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>;
|
||||||
log: RuntimeDeliveryLog;
|
log: RuntimeDeliveryLog;
|
||||||
|
attachmentBaseDirs?: string[];
|
||||||
isPairedRoom: boolean;
|
isPairedRoom: boolean;
|
||||||
getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string;
|
getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string;
|
||||||
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
||||||
@@ -186,6 +204,7 @@ export async function processOpenWorkItemDelivery(args: {
|
|||||||
channel: deliveryChannel,
|
channel: deliveryChannel,
|
||||||
item: openWorkItem,
|
item: openWorkItem,
|
||||||
log: args.log,
|
log: args.log,
|
||||||
|
attachmentBaseDirs: args.attachmentBaseDirs,
|
||||||
isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal,
|
isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal,
|
||||||
openContinuation: args.openContinuation,
|
openContinuation: args.openContinuation,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
|
|||||||
import { normalizeMessageForDedupe } from './router.js';
|
import { normalizeMessageForDedupe } from './router.js';
|
||||||
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||||
import type {
|
import type {
|
||||||
|
AgentType,
|
||||||
Channel,
|
Channel,
|
||||||
NewMessage,
|
NewMessage,
|
||||||
AgentType,
|
OutboundAttachment,
|
||||||
PairedRoomRole,
|
PairedRoomRole,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -100,6 +101,7 @@ interface CreateExecuteTurnDeps {
|
|||||||
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
||||||
deliverFinalText: (args: {
|
deliverFinalText: (args: {
|
||||||
text: string;
|
text: string;
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
@@ -202,6 +204,9 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
|
|||||||
try {
|
try {
|
||||||
return await deps.deliverFinalText({
|
return await deps.deliverFinalText({
|
||||||
text,
|
text,
|
||||||
|
...(options?.attachments?.length
|
||||||
|
? { attachments: options.attachments }
|
||||||
|
: {}),
|
||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
channel,
|
channel,
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
clearSession: deps.clearSession,
|
clearSession: deps.clearSession,
|
||||||
deliverFinalText: async ({
|
deliverFinalText: async ({
|
||||||
text,
|
text,
|
||||||
|
attachments,
|
||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
channel,
|
channel,
|
||||||
@@ -248,11 +249,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
start_seq: startSeq,
|
start_seq: startSeq,
|
||||||
end_seq: endSeq,
|
end_seq: endSeq,
|
||||||
result_payload: text,
|
result_payload: text,
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
return deliverOpenWorkItem({
|
return deliverOpenWorkItem({
|
||||||
channel,
|
channel,
|
||||||
item: workItem,
|
item: workItem,
|
||||||
log: logger,
|
log: logger,
|
||||||
|
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
|
||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
||||||
openContinuation: (targetChatJid) =>
|
openContinuation: (targetChatJid) =>
|
||||||
@@ -455,6 +458,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
channel,
|
channel,
|
||||||
roleToChannel,
|
roleToChannel,
|
||||||
log,
|
log,
|
||||||
|
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
|
||||||
isPairedRoom: hasReviewerLease(chatJid),
|
isPairedRoom: hasReviewerLease(chatJid),
|
||||||
getMissingRoleChannelMessage,
|
getMissingRoleChannelMessage,
|
||||||
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
||||||
|
|||||||
@@ -317,6 +317,62 @@ describe('MessageTurnController outbound audit logging', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes structured final attachments to final delivery', async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
const deliverFinalText = vi.fn().mockResolvedValue(true);
|
||||||
|
const attachments = [
|
||||||
|
{
|
||||||
|
path: '/tmp/e2e-screenshot.png',
|
||||||
|
name: 'e2e-screenshot.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const controller = new MessageTurnController({
|
||||||
|
chatJid: 'dc:test-room',
|
||||||
|
group: makeGroup(),
|
||||||
|
runId: 'run-review-attachments',
|
||||||
|
channel,
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
failureFinalText: '실패',
|
||||||
|
isClaudeCodeAgent: true,
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
deliverFinalText,
|
||||||
|
deliveryRole: 'reviewer',
|
||||||
|
deliveryServiceId: 'codex-review',
|
||||||
|
pairedTurnIdentity: makeTurnIdentity(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await controller.handleOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'final',
|
||||||
|
result: '스크린샷을 첨부했습니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '스크린샷을 첨부했습니다.',
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
await controller.finish('success');
|
||||||
|
|
||||||
|
expect(deliverFinalText).toHaveBeenCalledWith(
|
||||||
|
'스크린샷을 첨부했습니다.',
|
||||||
|
{
|
||||||
|
replaceMessageId: null,
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(getAuditEntries()).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
auditEvent: 'final-delivery-attempt',
|
||||||
|
attachmentCount: 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('replaces the tracked progress message when an owner final arrives', async () => {
|
it('replaces the tracked progress message when an owner final arrives', async () => {
|
||||||
const channel = {
|
const channel = {
|
||||||
...makeChannel(),
|
...makeChannel(),
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { type AgentOutput } from './agent-runner.js';
|
import { type AgentOutput } from './agent-runner.js';
|
||||||
import { getAgentOutputText } from './agent-output.js';
|
import {
|
||||||
|
getAgentOutputAttachments,
|
||||||
|
getAgentOutputText,
|
||||||
|
} from './agent-output.js';
|
||||||
import { createScopedLogger, logger } from './logger.js';
|
import { createScopedLogger, logger } from './logger.js';
|
||||||
import { formatOutbound } from './router.js';
|
import { formatOutbound } from './router.js';
|
||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
toVisiblePhase,
|
toVisiblePhase,
|
||||||
type AgentOutputPhase,
|
type AgentOutputPhase,
|
||||||
type Channel,
|
type Channel,
|
||||||
|
type OutboundAttachment,
|
||||||
type PairedRoomRole,
|
type PairedRoomRole,
|
||||||
type RegisteredGroup,
|
type RegisteredGroup,
|
||||||
type VisiblePhase,
|
type VisiblePhase,
|
||||||
@@ -35,7 +39,10 @@ interface MessageTurnControllerOptions {
|
|||||||
requestClose: (reason: string) => void;
|
requestClose: (reason: string) => void;
|
||||||
deliverFinalText: (
|
deliverFinalText: (
|
||||||
text: string,
|
text: string,
|
||||||
options?: { replaceMessageId?: string | null },
|
options?: {
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
|
replaceMessageId?: string | null;
|
||||||
|
},
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
canDeliverFinalText?: () => boolean;
|
canDeliverFinalText?: () => boolean;
|
||||||
allowProgressReplayWithoutFinal?: boolean;
|
allowProgressReplayWithoutFinal?: boolean;
|
||||||
@@ -160,6 +167,7 @@ export class MessageTurnController {
|
|||||||
|
|
||||||
const raw = getAgentOutputText(result);
|
const raw = getAgentOutputText(result);
|
||||||
const text = raw ? formatOutbound(raw) : null;
|
const text = raw ? formatOutbound(raw) : null;
|
||||||
|
const attachments = getAgentOutputAttachments(result);
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
@@ -297,6 +305,7 @@ export class MessageTurnController {
|
|||||||
// then discard the pending buffer so it never shows up.
|
// then discard the pending buffer so it never shows up.
|
||||||
if (text) {
|
if (text) {
|
||||||
await this.publishTerminalText(text, {
|
await this.publishTerminalText(text, {
|
||||||
|
attachments,
|
||||||
flushPendingText: text,
|
flushPendingText: text,
|
||||||
});
|
});
|
||||||
} else if (raw) {
|
} else if (raw) {
|
||||||
@@ -648,14 +657,22 @@ export class MessageTurnController {
|
|||||||
|
|
||||||
private async publishTerminalText(
|
private async publishTerminalText(
|
||||||
text: string,
|
text: string,
|
||||||
options?: { flushPendingText?: string | null },
|
options?: {
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
|
flushPendingText?: string | null;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (options?.flushPendingText) {
|
if (options?.flushPendingText) {
|
||||||
await this.flushPendingProgress(options.flushPendingText);
|
await this.flushPendingProgress(options.flushPendingText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceMessageId = this.consumeProgressForFinalDelivery();
|
const replaceMessageId = this.consumeProgressForFinalDelivery();
|
||||||
await this.deliverFinalText(text, { replaceMessageId });
|
await this.deliverFinalText(text, {
|
||||||
|
...(options?.attachments?.length
|
||||||
|
? { attachments: options.attachments }
|
||||||
|
: {}),
|
||||||
|
replaceMessageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private consumeProgressForFinalDelivery(): string | null {
|
private consumeProgressForFinalDelivery(): string | null {
|
||||||
@@ -675,7 +692,10 @@ export class MessageTurnController {
|
|||||||
|
|
||||||
private async deliverFinalText(
|
private async deliverFinalText(
|
||||||
text: string,
|
text: string,
|
||||||
options?: { replaceMessageId?: string | null },
|
options?: {
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
|
replaceMessageId?: string | null;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.activateTyping('turn:deliver-final');
|
await this.activateTyping('turn:deliver-final');
|
||||||
this.visiblePhase = toVisiblePhase('final');
|
this.visiblePhase = toVisiblePhase('final');
|
||||||
@@ -697,14 +717,19 @@ export class MessageTurnController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.logOutboundAudit('final-delivery-attempt', {
|
this.logOutboundAudit('final-delivery-attempt', {
|
||||||
|
attachmentCount: options?.attachments?.length ?? 0,
|
||||||
messageId: replaceMessageId,
|
messageId: replaceMessageId,
|
||||||
textLength: text.length,
|
textLength: text.length,
|
||||||
deliveryMode: replaceMessageId ? 'edit' : 'send',
|
deliveryMode: replaceMessageId ? 'edit' : 'send',
|
||||||
});
|
});
|
||||||
const delivered = await this.options.deliverFinalText(text, {
|
const delivered = await this.options.deliverFinalText(text, {
|
||||||
|
...(options?.attachments?.length
|
||||||
|
? { attachments: options.attachments }
|
||||||
|
: {}),
|
||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
});
|
});
|
||||||
this.logOutboundAudit('final-delivery-result', {
|
this.logOutboundAudit('final-delivery-result', {
|
||||||
|
attachmentCount: options?.attachments?.length ?? 0,
|
||||||
messageId: replaceMessageId,
|
messageId: replaceMessageId,
|
||||||
textLength: text.length,
|
textLength: text.length,
|
||||||
deliveryMode: replaceMessageId ? 'edit' : 'send',
|
deliveryMode: replaceMessageId ? 'edit' : 'send',
|
||||||
|
|||||||
149
src/outbound-attachments.test.ts
Normal file
149
src/outbound-attachments.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { validateOutboundAttachments } from './outbound-attachments.js';
|
||||||
|
|
||||||
|
const ONE_PIXEL_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupDirs: string[] = [];
|
||||||
|
|
||||||
|
function makeTempDir(baseDir: string, prefix: string): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(baseDir, prefix));
|
||||||
|
cleanupDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(
|
||||||
|
dir: string,
|
||||||
|
name: string,
|
||||||
|
content: Buffer | string,
|
||||||
|
): string {
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
fs.writeFileSync(filePath, content);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of cleanupDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateOutboundAttachments', () => {
|
||||||
|
it('accepts real image files under default attachment directories', () => {
|
||||||
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||||
|
const imagePath = writeFile(dir, 'screenshot.png', ONE_PIXEL_PNG);
|
||||||
|
|
||||||
|
const result = validateOutboundAttachments([
|
||||||
|
{
|
||||||
|
path: imagePath,
|
||||||
|
name: '../unsafe-name.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.rejected).toEqual([]);
|
||||||
|
expect(result.files).toEqual([
|
||||||
|
{
|
||||||
|
attachment: fs.realpathSync(imagePath),
|
||||||
|
name: 'unsafe-name.png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(validateOutboundAttachments([{ path: imagePath }])).toEqual({
|
||||||
|
files: [],
|
||||||
|
rejected: [{ path: imagePath, reason: 'outside-allowed-dirs' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowed = validateOutboundAttachments([{ path: imagePath }], {
|
||||||
|
baseDirs: [dir],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allowed.rejected).toEqual([]);
|
||||||
|
expect(allowed.files).toEqual([
|
||||||
|
{
|
||||||
|
attachment: fs.realpathSync(imagePath),
|
||||||
|
name: 'workspace-shot.png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects symlink attempts that escape the allowed directory', () => {
|
||||||
|
const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
||||||
|
const targetPath = writeFile(
|
||||||
|
workspaceDir,
|
||||||
|
'secret-shot.png',
|
||||||
|
ONE_PIXEL_PNG,
|
||||||
|
);
|
||||||
|
const tmpDir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||||
|
const linkPath = path.join(tmpDir, 'linked-shot.png');
|
||||||
|
fs.symlinkSync(targetPath, linkPath);
|
||||||
|
|
||||||
|
const result = validateOutboundAttachments([{ path: linkPath }]);
|
||||||
|
|
||||||
|
expect(result.files).toEqual([]);
|
||||||
|
expect(result.rejected).toEqual([
|
||||||
|
{ path: linkPath, reason: 'outside-allowed-dirs' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SVG attachments before inspecting file content', () => {
|
||||||
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||||
|
const svgPath = writeFile(dir, 'vector.svg', '<svg></svg>');
|
||||||
|
|
||||||
|
const result = validateOutboundAttachments([{ path: svgPath }]);
|
||||||
|
|
||||||
|
expect(result.files).toEqual([]);
|
||||||
|
expect(result.rejected).toEqual([
|
||||||
|
{ path: svgPath, reason: 'unsupported-extension' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects files whose extension and image signature do not match policy', () => {
|
||||||
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||||
|
const fakePng = writeFile(dir, 'fake.png', 'not an image');
|
||||||
|
const realPng = writeFile(dir, 'real.png', ONE_PIXEL_PNG);
|
||||||
|
|
||||||
|
expect(validateOutboundAttachments([{ path: fakePng }])).toEqual({
|
||||||
|
files: [],
|
||||||
|
rejected: [{ path: fakePng, reason: 'invalid-image-signature' }],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
validateOutboundAttachments([{ path: realPng, mime: 'image/jpeg' }]),
|
||||||
|
).toEqual({
|
||||||
|
files: [],
|
||||||
|
rejected: [{ path: realPng, reason: 'mime-mismatch' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-files and files over the size cap', () => {
|
||||||
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||||
|
const nestedDir = path.join(dir, 'nested.png');
|
||||||
|
fs.mkdirSync(nestedDir);
|
||||||
|
const largePng = writeFile(
|
||||||
|
dir,
|
||||||
|
'large.png',
|
||||||
|
Buffer.concat([ONE_PIXEL_PNG, Buffer.alloc(8 * 1024 * 1024)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(validateOutboundAttachments([{ path: nestedDir }])).toEqual({
|
||||||
|
files: [],
|
||||||
|
rejected: [{ path: nestedDir, reason: 'not-file' }],
|
||||||
|
});
|
||||||
|
expect(validateOutboundAttachments([{ path: largePng }])).toEqual({
|
||||||
|
files: [],
|
||||||
|
rejected: [{ path: largePng, reason: 'too-large' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
174
src/outbound-attachments.ts
Normal file
174
src/outbound-attachments.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { DATA_DIR } from './config.js';
|
||||||
|
import type { OutboundAttachment } from './types.js';
|
||||||
|
|
||||||
|
export interface ValidatedOutboundAttachment {
|
||||||
|
attachment: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateOutboundAttachmentsResult {
|
||||||
|
files: ValidatedOutboundAttachment[];
|
||||||
|
rejected: Array<{ path: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||||
|
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||||
|
|
||||||
|
function unique(values: Array<string | null | undefined>): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(values.filter((value): value is string => Boolean(value))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExistingDir(dir: string): string | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dir)) return null;
|
||||||
|
return fs.realpathSync(dir);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultAttachmentBaseDirs(): string[] {
|
||||||
|
const home = process.env.HOME;
|
||||||
|
const codexHome =
|
||||||
|
process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null);
|
||||||
|
// Keep defaults narrow. Runtime-specific workspaces must be passed via
|
||||||
|
// attachmentBaseDirs so one room cannot attach another room's files by path.
|
||||||
|
return unique([
|
||||||
|
'/tmp',
|
||||||
|
path.join(DATA_DIR, 'attachments'),
|
||||||
|
codexHome ? path.join(codexHome, 'generated_images') : null,
|
||||||
|
])
|
||||||
|
.map(resolveExistingDir)
|
||||||
|
.filter((dir): dir is string => Boolean(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinBaseDir(realPath: string, baseDir: string): boolean {
|
||||||
|
const relative = path.relative(baseDir, realPath);
|
||||||
|
return (
|
||||||
|
relative === '' ||
|
||||||
|
(!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean {
|
||||||
|
return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageMime(filePath: string): string | null {
|
||||||
|
const handle = fs.openSync(filePath, 'r');
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(512);
|
||||||
|
const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
|
||||||
|
const header = buffer.subarray(0, bytesRead);
|
||||||
|
if (
|
||||||
|
header
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||||
|
) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
header.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||||
|
header.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||||
|
) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
header.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
header.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (header[0] === 0x42 && header[1] === 0x4d) {
|
||||||
|
return 'image/bmp';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentName(
|
||||||
|
attachment: OutboundAttachment,
|
||||||
|
realPath: string,
|
||||||
|
): string {
|
||||||
|
const candidate = attachment.name
|
||||||
|
? path.basename(attachment.name)
|
||||||
|
: path.basename(realPath);
|
||||||
|
return candidate || 'attachment';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateOutboundAttachments(
|
||||||
|
attachments: OutboundAttachment[] | undefined,
|
||||||
|
options: { baseDirs?: string[] } = {},
|
||||||
|
): ValidateOutboundAttachmentsResult {
|
||||||
|
const baseDirs = unique([
|
||||||
|
...getDefaultAttachmentBaseDirs(),
|
||||||
|
...(options.baseDirs ?? []).map(resolveExistingDir),
|
||||||
|
]).filter((dir): dir is string => Boolean(dir));
|
||||||
|
const files: ValidatedOutboundAttachment[] = [];
|
||||||
|
const rejected: Array<{ path: string; reason: string }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const attachment of attachments ?? []) {
|
||||||
|
const requestedPath = attachment.path;
|
||||||
|
try {
|
||||||
|
if (!path.isAbsolute(requestedPath)) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'not-absolute' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!IMAGE_EXTS.test(requestedPath)) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(requestedPath)) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'not-found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const realPath = fs.realpathSync(requestedPath);
|
||||||
|
if (seen.has(realPath)) continue;
|
||||||
|
const stat = fs.statSync(realPath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'not-file' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.size > MAX_ATTACHMENT_BYTES) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'too-large' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!matchesAllowedBaseDir(realPath, baseDirs)) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const detectedMime = detectImageMime(realPath);
|
||||||
|
if (!detectedMime) {
|
||||||
|
rejected.push({
|
||||||
|
path: requestedPath,
|
||||||
|
reason: 'invalid-image-signature',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attachment.mime && attachment.mime !== detectedMime) {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'mime-mismatch' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push({
|
||||||
|
attachment: realPath,
|
||||||
|
name: normalizeAttachmentName(attachment, realPath),
|
||||||
|
});
|
||||||
|
seen.add(realPath);
|
||||||
|
} catch {
|
||||||
|
rejected.push({ path: requestedPath, reason: 'validation-error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, rejected };
|
||||||
|
}
|
||||||
23
src/types.ts
23
src/types.ts
@@ -26,6 +26,22 @@ export type VisiblePhase = 'silent' | 'progress' | 'final';
|
|||||||
|
|
||||||
export type AgentVisibility = 'public' | 'silent';
|
export type AgentVisibility = 'public' | 'silent';
|
||||||
|
|
||||||
|
export interface OutboundAttachment {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
mime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
|
/**
|
||||||
|
* Extra realpath roots that are valid for this delivery attempt. Runtime
|
||||||
|
* callers can pass the active project/workspace directory without widening
|
||||||
|
* the global Discord attachment allowlist.
|
||||||
|
*/
|
||||||
|
attachmentBaseDirs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
|
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
|
||||||
|
|
||||||
export type PairedTaskStatus =
|
export type PairedTaskStatus =
|
||||||
@@ -126,6 +142,7 @@ export type StructuredAgentOutput =
|
|||||||
| {
|
| {
|
||||||
visibility: 'public';
|
visibility: 'public';
|
||||||
text: string;
|
text: string;
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
visibility: 'silent';
|
visibility: 'silent';
|
||||||
@@ -234,7 +251,11 @@ export interface ChannelOutboundAuditMeta {
|
|||||||
export interface Channel {
|
export interface Channel {
|
||||||
name: string;
|
name: string;
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
sendMessage(jid: string, text: string): Promise<void>;
|
sendMessage(
|
||||||
|
jid: string,
|
||||||
|
text: string,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<void>;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
ownsJid(jid: string): boolean;
|
ownsJid(jid: string): boolean;
|
||||||
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
||||||
|
|||||||
Reference in New Issue
Block a user