Add structured Discord attachments
This commit is contained in:
@@ -34,6 +34,16 @@ export function getAgentOutputText(output: {
|
||||
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: {
|
||||
output?: StructuredAgentOutput;
|
||||
result?: string | object | null;
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
OUTPUT_START_MARKER,
|
||||
writeProtocolOutput,
|
||||
type NormalizedRunnerOutput,
|
||||
type RunnerOutputAttachment,
|
||||
type RunnerOutputPhase,
|
||||
type RunnerOutputVerdict,
|
||||
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';
|
||||
|
||||
// --- Mocks ---
|
||||
@@ -127,6 +131,18 @@ import { logger } from '../logger.js';
|
||||
|
||||
// --- 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(
|
||||
overrides?: Partial<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 () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
} from '../config.js';
|
||||
import { getEnv } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { extractImageTagPaths } from '../agent-protocol.js';
|
||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||
import { formatOutbound } from '../router.js';
|
||||
import { hasReviewerLease } from '../service-routing.js';
|
||||
import type { OutboundAttachment, SendMessageOptions } from '../types.js';
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||
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_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_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.
|
||||
@@ -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) {
|
||||
logger.warn('Discord client not initialized');
|
||||
return;
|
||||
@@ -420,37 +466,43 @@ export class DiscordChannel implements Channel {
|
||||
|
||||
const textChannel = channel as TextChannel;
|
||||
|
||||
// Extract image attachments from markdown links with image extensions
|
||||
// e.g. [name.png](/absolute/path/name.png)
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|bmp)$/i;
|
||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
||||
const imageFiles: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let match;
|
||||
const structuredAttachments = options.attachments ?? [];
|
||||
const hasStructuredAttachments = structuredAttachments.length > 0;
|
||||
const markdownExtracted = extractMarkdownImageAttachments(text);
|
||||
const imageTagExtracted = extractImageTagPaths(
|
||||
markdownExtracted.cleanText,
|
||||
);
|
||||
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) {
|
||||
const imgPath = match[1].trim();
|
||||
if (
|
||||
!seen.has(imgPath) &&
|
||||
IMAGE_EXTS.test(imgPath) &&
|
||||
fs.existsSync(imgPath)
|
||||
) {
|
||||
imageFiles.push(imgPath);
|
||||
seen.add(imgPath);
|
||||
}
|
||||
if (validation.rejected.length > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
attachmentSource,
|
||||
rejected: validation.rejected,
|
||||
},
|
||||
'Rejected outbound Discord attachments',
|
||||
);
|
||||
}
|
||||
let cleaned = text
|
||||
.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}\``;
|
||||
})
|
||||
|
||||
let cleaned = imageTagExtracted.cleanText
|
||||
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
|
||||
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
|
||||
.trim();
|
||||
@@ -467,10 +519,6 @@ export class DiscordChannel implements Channel {
|
||||
// Discord has a 2000 character limit per message and 10 attachments per message
|
||||
const MAX_LENGTH = 2000;
|
||||
const MAX_ATTACHMENTS = 10;
|
||||
const files = imageFiles.map((f) => ({
|
||||
attachment: f,
|
||||
name: path.basename(f),
|
||||
}));
|
||||
const sentMessageIds: string[] = [];
|
||||
let chunkCount = 0;
|
||||
|
||||
@@ -544,6 +592,7 @@ export class DiscordChannel implements Channel {
|
||||
deliveryMode: 'send',
|
||||
chunkCount,
|
||||
attachmentCount: files.length,
|
||||
attachmentSource,
|
||||
messageId: sentMessageIds[0] ?? null,
|
||||
messageIds: sentMessageIds,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
|
||||
@@ -9106,6 +9106,38 @@ describe('work items', () => {
|
||||
).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', () => {
|
||||
const fallbackItem = createProducedWorkItem({
|
||||
group_folder: 'discord_test',
|
||||
|
||||
@@ -39,6 +39,7 @@ export function applyBaseSchema(database: Database): void {
|
||||
start_seq INTEGER,
|
||||
end_seq INTEGER,
|
||||
result_payload TEXT NOT NULL,
|
||||
attachment_payload TEXT,
|
||||
delivery_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
delivery_message_id TEXT,
|
||||
last_error TEXT,
|
||||
|
||||
@@ -38,6 +38,7 @@ function getExpectedSchemaMigrations(): Array<{
|
||||
{ version: 11, name: 'owner_failure_count' },
|
||||
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
||||
{ 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 { 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 { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
|
||||
import type {
|
||||
SchemaMigrationArgs,
|
||||
SchemaMigrationDefinition,
|
||||
@@ -34,6 +35,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
||||
OWNER_FAILURE_COUNT_MIGRATION,
|
||||
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
||||
MESSAGE_SOURCE_KIND_MIGRATION,
|
||||
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
||||
];
|
||||
|
||||
function ensureSchemaMigrationsTable(database: Database): void {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
inferRoleFromServiceShadow,
|
||||
resolveRoleServiceShadow,
|
||||
} from '../role-service-shadow.js';
|
||||
import { AgentType, PairedRoomRole } from '../types.js';
|
||||
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
|
||||
|
||||
export interface WorkItem {
|
||||
id: number;
|
||||
@@ -19,6 +19,7 @@ export interface WorkItem {
|
||||
start_seq: number | null;
|
||||
end_seq: number | null;
|
||||
result_payload: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
delivery_attempts: number;
|
||||
delivery_message_id: string | null;
|
||||
last_error: string | null;
|
||||
@@ -29,10 +30,11 @@ export interface WorkItem {
|
||||
|
||||
interface StoredWorkItemRow extends Omit<
|
||||
WorkItem,
|
||||
'agent_type' | 'service_id'
|
||||
'agent_type' | 'service_id' | 'attachments'
|
||||
> {
|
||||
agent_type: string;
|
||||
service_id?: string | null;
|
||||
attachment_payload?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProducedWorkItemInput {
|
||||
@@ -44,6 +46,7 @@ export interface CreateProducedWorkItemInput {
|
||||
start_seq: number | null;
|
||||
end_seq: number | null;
|
||||
result_payload: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
}
|
||||
|
||||
function normalizeStoredAgentType(
|
||||
@@ -96,9 +99,48 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
|
||||
...row,
|
||||
agent_type: agentType,
|
||||
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(
|
||||
serviceId: string | null | undefined,
|
||||
): PairedRoomRole | null {
|
||||
@@ -220,10 +262,11 @@ export function createProducedWorkItemInDatabase(
|
||||
start_seq,
|
||||
end_seq,
|
||||
result_payload,
|
||||
attachment_payload,
|
||||
delivery_attempts,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.group_folder,
|
||||
@@ -234,6 +277,7 @@ export function createProducedWorkItemInDatabase(
|
||||
input.start_seq,
|
||||
input.end_seq,
|
||||
input.result_payload,
|
||||
serializeAttachmentPayload(input.attachments),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -28,11 +28,14 @@ export async function deliverOpenWorkItem(args: {
|
||||
channel: Channel;
|
||||
item: WorkItem;
|
||||
log: RuntimeDeliveryLog;
|
||||
attachmentBaseDirs?: string[];
|
||||
replaceMessageId?: string | null;
|
||||
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
||||
openContinuation: (chatJid: string) => void;
|
||||
}): Promise<boolean> {
|
||||
const replaceMessageId = args.replaceMessageId ?? null;
|
||||
const attachments = args.item.attachments ?? [];
|
||||
const hasAttachments = attachments.length > 0;
|
||||
|
||||
const isDuplicate = args.isDuplicateOfLastBotFinal(
|
||||
args.item.chat_jid,
|
||||
@@ -52,7 +55,7 @@ export async function deliverOpenWorkItem(args: {
|
||||
}
|
||||
|
||||
try {
|
||||
if (replaceMessageId && args.channel.editMessage) {
|
||||
if (replaceMessageId && args.channel.editMessage && !hasAttachments) {
|
||||
args.log.info(
|
||||
buildDeliveryLogContext(args.channel, args.item, {
|
||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||
@@ -93,19 +96,32 @@ export async function deliverOpenWorkItem(args: {
|
||||
try {
|
||||
args.log.info(
|
||||
buildDeliveryLogContext(args.channel, args.item, {
|
||||
attachmentCount: attachments.length,
|
||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||
deliveryMode: 'send',
|
||||
}),
|
||||
'Attempting to deliver produced work item as a new message',
|
||||
);
|
||||
await args.channel.sendMessage(
|
||||
args.item.chat_jid,
|
||||
args.item.result_payload,
|
||||
);
|
||||
if (hasAttachments) {
|
||||
await args.channel.sendMessage(
|
||||
args.item.chat_jid,
|
||||
args.item.result_payload,
|
||||
{
|
||||
attachmentBaseDirs: args.attachmentBaseDirs,
|
||||
attachments,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await args.channel.sendMessage(
|
||||
args.item.chat_jid,
|
||||
args.item.result_payload,
|
||||
);
|
||||
}
|
||||
markWorkItemDelivered(args.item.id);
|
||||
args.openContinuation(args.item.chat_jid);
|
||||
args.log.info(
|
||||
buildDeliveryLogContext(args.channel, args.item, {
|
||||
attachmentCount: attachments.length,
|
||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||
deliveryMode: 'send',
|
||||
}),
|
||||
@@ -117,6 +133,7 @@ export async function deliverOpenWorkItem(args: {
|
||||
markWorkItemDeliveryRetry(args.item.id, errorMessage);
|
||||
args.log.warn(
|
||||
buildDeliveryLogContext(args.channel, args.item, {
|
||||
attachmentCount: attachments.length,
|
||||
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||
deliveryMode: 'send',
|
||||
err,
|
||||
@@ -135,6 +152,7 @@ export async function processOpenWorkItemDelivery(args: {
|
||||
channel: Channel;
|
||||
roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>;
|
||||
log: RuntimeDeliveryLog;
|
||||
attachmentBaseDirs?: string[];
|
||||
isPairedRoom: boolean;
|
||||
getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string;
|
||||
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
||||
@@ -186,6 +204,7 @@ export async function processOpenWorkItemDelivery(args: {
|
||||
channel: deliveryChannel,
|
||||
item: openWorkItem,
|
||||
log: args.log,
|
||||
attachmentBaseDirs: args.attachmentBaseDirs,
|
||||
isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal,
|
||||
openContinuation: args.openContinuation,
|
||||
});
|
||||
|
||||
@@ -11,9 +11,10 @@ import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
|
||||
import { normalizeMessageForDedupe } from './router.js';
|
||||
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||
import type {
|
||||
AgentType,
|
||||
Channel,
|
||||
NewMessage,
|
||||
AgentType,
|
||||
OutboundAttachment,
|
||||
PairedRoomRole,
|
||||
RegisteredGroup,
|
||||
} from './types.js';
|
||||
@@ -100,6 +101,7 @@ interface CreateExecuteTurnDeps {
|
||||
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
||||
deliverFinalText: (args: {
|
||||
text: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
chatJid: string;
|
||||
runId: string;
|
||||
channel: Channel;
|
||||
@@ -202,6 +204,9 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
|
||||
try {
|
||||
return await deps.deliverFinalText({
|
||||
text,
|
||||
...(options?.attachments?.length
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
chatJid,
|
||||
runId,
|
||||
channel,
|
||||
|
||||
@@ -210,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
clearSession: deps.clearSession,
|
||||
deliverFinalText: async ({
|
||||
text,
|
||||
attachments,
|
||||
chatJid,
|
||||
runId,
|
||||
channel,
|
||||
@@ -248,11 +249,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
start_seq: startSeq,
|
||||
end_seq: endSeq,
|
||||
result_payload: text,
|
||||
attachments,
|
||||
});
|
||||
return deliverOpenWorkItem({
|
||||
channel,
|
||||
item: workItem,
|
||||
log: logger,
|
||||
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
|
||||
replaceMessageId,
|
||||
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
||||
openContinuation: (targetChatJid) =>
|
||||
@@ -455,6 +458,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
channel,
|
||||
roleToChannel,
|
||||
log,
|
||||
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
|
||||
isPairedRoom: hasReviewerLease(chatJid),
|
||||
getMissingRoleChannelMessage,
|
||||
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 () => {
|
||||
const channel = {
|
||||
...makeChannel(),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { formatOutbound } from './router.js';
|
||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
toVisiblePhase,
|
||||
type AgentOutputPhase,
|
||||
type Channel,
|
||||
type OutboundAttachment,
|
||||
type PairedRoomRole,
|
||||
type RegisteredGroup,
|
||||
type VisiblePhase,
|
||||
@@ -35,7 +39,10 @@ interface MessageTurnControllerOptions {
|
||||
requestClose: (reason: string) => void;
|
||||
deliverFinalText: (
|
||||
text: string,
|
||||
options?: { replaceMessageId?: string | null },
|
||||
options?: {
|
||||
attachments?: OutboundAttachment[];
|
||||
replaceMessageId?: string | null;
|
||||
},
|
||||
) => Promise<boolean>;
|
||||
canDeliverFinalText?: () => boolean;
|
||||
allowProgressReplayWithoutFinal?: boolean;
|
||||
@@ -160,6 +167,7 @@ export class MessageTurnController {
|
||||
|
||||
const raw = getAgentOutputText(result);
|
||||
const text = raw ? formatOutbound(raw) : null;
|
||||
const attachments = getAgentOutputAttachments(result);
|
||||
|
||||
if (raw) {
|
||||
this.log.info(
|
||||
@@ -297,6 +305,7 @@ export class MessageTurnController {
|
||||
// then discard the pending buffer so it never shows up.
|
||||
if (text) {
|
||||
await this.publishTerminalText(text, {
|
||||
attachments,
|
||||
flushPendingText: text,
|
||||
});
|
||||
} else if (raw) {
|
||||
@@ -648,14 +657,22 @@ export class MessageTurnController {
|
||||
|
||||
private async publishTerminalText(
|
||||
text: string,
|
||||
options?: { flushPendingText?: string | null },
|
||||
options?: {
|
||||
attachments?: OutboundAttachment[];
|
||||
flushPendingText?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (options?.flushPendingText) {
|
||||
await this.flushPendingProgress(options.flushPendingText);
|
||||
}
|
||||
|
||||
const replaceMessageId = this.consumeProgressForFinalDelivery();
|
||||
await this.deliverFinalText(text, { replaceMessageId });
|
||||
await this.deliverFinalText(text, {
|
||||
...(options?.attachments?.length
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
replaceMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
private consumeProgressForFinalDelivery(): string | null {
|
||||
@@ -675,7 +692,10 @@ export class MessageTurnController {
|
||||
|
||||
private async deliverFinalText(
|
||||
text: string,
|
||||
options?: { replaceMessageId?: string | null },
|
||||
options?: {
|
||||
attachments?: OutboundAttachment[];
|
||||
replaceMessageId?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.activateTyping('turn:deliver-final');
|
||||
this.visiblePhase = toVisiblePhase('final');
|
||||
@@ -697,14 +717,19 @@ export class MessageTurnController {
|
||||
return;
|
||||
}
|
||||
this.logOutboundAudit('final-delivery-attempt', {
|
||||
attachmentCount: options?.attachments?.length ?? 0,
|
||||
messageId: replaceMessageId,
|
||||
textLength: text.length,
|
||||
deliveryMode: replaceMessageId ? 'edit' : 'send',
|
||||
});
|
||||
const delivered = await this.options.deliverFinalText(text, {
|
||||
...(options?.attachments?.length
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
replaceMessageId,
|
||||
});
|
||||
this.logOutboundAudit('final-delivery-result', {
|
||||
attachmentCount: options?.attachments?.length ?? 0,
|
||||
messageId: replaceMessageId,
|
||||
textLength: text.length,
|
||||
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 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 PairedTaskStatus =
|
||||
@@ -126,6 +142,7 @@ export type StructuredAgentOutput =
|
||||
| {
|
||||
visibility: 'public';
|
||||
text: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
}
|
||||
| {
|
||||
visibility: 'silent';
|
||||
@@ -234,7 +251,11 @@ export interface ChannelOutboundAuditMeta {
|
||||
export interface Channel {
|
||||
name: string;
|
||||
connect(): Promise<void>;
|
||||
sendMessage(jid: string, text: string): Promise<void>;
|
||||
sendMessage(
|
||||
jid: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
||||
|
||||
Reference in New Issue
Block a user