chore: test deploy snapshot
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { resolvePromptFollowupMode, resolveStaticContentType } from './chat.js';
|
||||
import Fastify from 'fastify';
|
||||
import { registerChatRoutes, resolvePromptFollowupMode, resolveStaticContentType } from './chat.js';
|
||||
|
||||
const repoRoot = path.resolve(process.cwd(), '../../..');
|
||||
|
||||
async function removeSessionUploads(sessionId: string) {
|
||||
await fs.rm(path.join(repoRoot, 'public', '.codex_chat', sessionId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
@@ -18,3 +30,66 @@ test('resolvePromptFollowupMode defaults to queue and preserves direct mode', ()
|
||||
assert.equal(resolvePromptFollowupMode('queue'), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode('direct'), 'direct');
|
||||
});
|
||||
|
||||
test('chat attachments accept binary octet-stream uploads without base64 expansion', async () => {
|
||||
const app = Fastify();
|
||||
await registerChatRoutes(app);
|
||||
const sessionId = `binary-upload-${Date.now()}`;
|
||||
const payload = Buffer.alloc(829_627, 1);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/chat/attachments',
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream',
|
||||
'x-chat-attachment-session-id': sessionId,
|
||||
'x-chat-attachment-file-name': encodeURIComponent('image.png'),
|
||||
'x-chat-attachment-mime-type': encodeURIComponent('image/png'),
|
||||
},
|
||||
payload,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const body = response.json() as { ok: boolean; item: { path: string; size: number; mimeType: string } };
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.item.size, payload.byteLength);
|
||||
assert.equal(body.item.mimeType, 'image/png');
|
||||
assert.match(body.item.path, new RegExp(`^public/\\.codex_chat/${sessionId}/resource/uploads/.+image\\.png$`));
|
||||
} finally {
|
||||
await removeSessionUploads(sessionId);
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('chat attachments keep legacy JSON base64 uploads working', async () => {
|
||||
const app = Fastify();
|
||||
await registerChatRoutes(app);
|
||||
const sessionId = `json-upload-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/chat/attachments',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: JSON.stringify({
|
||||
sessionId,
|
||||
fileName: 'note.txt',
|
||||
mimeType: 'text/plain',
|
||||
contentBase64: Buffer.from('hello', 'utf8').toString('base64'),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const body = response.json() as { ok: boolean; item: { size: number; mimeType: string; name: string } };
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.item.size, 5);
|
||||
assert.equal(body.item.mimeType, 'text/plain');
|
||||
assert.equal(body.item.name, 'note.txt');
|
||||
} finally {
|
||||
await removeSessionUploads(sessionId);
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getChatShareTokenRoomMap,
|
||||
resolveChatShareTokenRoomSessionIds,
|
||||
upsertChatShareTokenRoomMap,
|
||||
type ChatShareRoomLinkContext,
|
||||
type ChatShareTokenRoomMapItem,
|
||||
} from '../services/chat-share-room-map-service.js';
|
||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||
@@ -62,8 +63,8 @@ import { resolveMainProjectRoot } from '../services/main-project-root-service.js
|
||||
import { openResourceManagerPreviewStream } from '../services/resource-manager-service.js';
|
||||
import { getTokenSettingById, type TokenSettingRecord } from '../services/token-setting-config-service.js';
|
||||
|
||||
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 300 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 450 * 1024 * 1024;
|
||||
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
||||
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
||||
const CHAT_SHARE_ROUTE_PREFIX = '/api/chat/shares';
|
||||
@@ -248,6 +249,7 @@ type ChatShareResolvedRoom = {
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
notifyOffline: boolean;
|
||||
linkContext: ChatShareRoomLinkContext | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
@@ -265,6 +267,7 @@ function mapResolvedShareRoomItem(room: ChatShareTokenRoomMapItem): ChatShareRes
|
||||
contextLabel: room.contextLabel,
|
||||
contextDescription: room.contextDescription,
|
||||
notifyOffline: room.notifyOffline,
|
||||
linkContext: room.linkContext,
|
||||
createdAt: room.createdAt,
|
||||
updatedAt: room.conversationUpdatedAt ?? room.updatedAt,
|
||||
};
|
||||
@@ -292,6 +295,7 @@ async function resolveManagedShareRooms(args: {
|
||||
contextLabel: null,
|
||||
contextDescription: null,
|
||||
notifyOffline: false,
|
||||
linkContext: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
} satisfies ChatShareResolvedRoom,
|
||||
@@ -319,6 +323,7 @@ async function resolveManagedShareRooms(args: {
|
||||
contextLabel: null,
|
||||
contextDescription: null,
|
||||
notifyOffline: false,
|
||||
linkContext: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
} satisfies ChatShareResolvedRoom,
|
||||
@@ -370,6 +375,31 @@ function createManagedChatShareMessageIds() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeShareRoomLinkContext(input: {
|
||||
linkedSessionId?: string | null;
|
||||
linkedRequestId?: string | null;
|
||||
linkedTitle?: string | null;
|
||||
linkedRequestPreview?: string | null;
|
||||
linkedChatTypeLabel?: string | null;
|
||||
}): ChatShareRoomLinkContext | null {
|
||||
const sourceSessionId = input.linkedSessionId?.trim() || '';
|
||||
const sourceRequestId = input.linkedRequestId?.trim() || '';
|
||||
|
||||
if (!sourceSessionId || !sourceRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'linked-session',
|
||||
sourceSessionId,
|
||||
sourceRequestId,
|
||||
sourceTitle: input.linkedTitle?.trim() || null,
|
||||
sourceRequestPreview: input.linkedRequestPreview?.trim() || null,
|
||||
sourceChatTypeLabel: input.linkedChatTypeLabel?.trim() || null,
|
||||
linkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function sortShareMessages(messages: ListedChatConversationMessage[]) {
|
||||
return [...messages].sort((left, right) => {
|
||||
if (left.id !== right.id) {
|
||||
@@ -700,6 +730,21 @@ async function saveChatAttachmentFile(args: {
|
||||
contentBase64: string;
|
||||
}) {
|
||||
const buffer = Buffer.from(args.contentBase64, 'base64');
|
||||
return saveChatAttachmentBuffer({
|
||||
sessionId: args.sessionId,
|
||||
fileName: args.fileName,
|
||||
mimeType: args.mimeType,
|
||||
buffer,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveChatAttachmentBuffer(args: {
|
||||
sessionId: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
buffer: Buffer;
|
||||
}) {
|
||||
const buffer = args.buffer;
|
||||
|
||||
if (buffer.byteLength === 0) {
|
||||
return {
|
||||
@@ -713,7 +758,7 @@ async function saveChatAttachmentFile(args: {
|
||||
return {
|
||||
ok: false as const,
|
||||
statusCode: 413,
|
||||
message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.',
|
||||
message: '첨부 파일은 300MB 이하만 업로드할 수 있습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -746,6 +791,62 @@ async function saveChatAttachmentFile(args: {
|
||||
};
|
||||
}
|
||||
|
||||
const chatAttachmentJsonBodySchema = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
||||
fileName: z.string().trim().max(255).optional(),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
||||
});
|
||||
|
||||
const chatAttachmentShareJsonBodySchema = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
|
||||
fileName: z.string().trim().max(255).optional(),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
||||
});
|
||||
|
||||
function decodeChatAttachmentHeaderValue(value: unknown) {
|
||||
const normalized = String(Array.isArray(value) ? value[0] ?? '' : value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatAttachmentBinaryHeaders(headers: Record<string, unknown>, options?: { allowOptionalSessionId?: boolean }) {
|
||||
const sessionId = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-session-id']);
|
||||
const fileName = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-file-name']);
|
||||
const mimeType = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-mime-type']);
|
||||
const schema = options?.allowOptionalSessionId
|
||||
? z.object({
|
||||
sessionId: z.string().trim().max(120).regex(/^[A-Za-z0-9._:-]+$/).optional().nullable(),
|
||||
fileName: z.string().trim().max(255).optional(),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
})
|
||||
: z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
||||
fileName: z.string().trim().max(255).optional(),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
return schema.parse({
|
||||
sessionId: sessionId || (options?.allowOptionalSessionId ? null : ''),
|
||||
fileName: fileName || undefined,
|
||||
mimeType: mimeType || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function isOctetStreamRequest(contentTypeHeader: unknown) {
|
||||
const contentType = String(Array.isArray(contentTypeHeader) ? contentTypeHeader[0] ?? '' : contentTypeHeader ?? '').toLowerCase();
|
||||
return contentType.startsWith('application/octet-stream');
|
||||
}
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
||||
const raw = request.headers['x-client-id'];
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
@@ -1119,22 +1220,32 @@ async function buildChatShareSnapshot(
|
||||
options?: {
|
||||
sessionId?: string | null;
|
||||
requestId?: string | null;
|
||||
detailLevel?: 'full' | 'initial';
|
||||
},
|
||||
) {
|
||||
const normalizedSessionId = options?.sessionId?.trim() || tokenPayload.sessionId.trim();
|
||||
const detailLevel = options?.detailLevel === 'initial' ? 'initial' : 'full';
|
||||
const conversation = await getChatConversation(normalizedSessionId, null);
|
||||
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [requests, messages] = await Promise.all([
|
||||
listChatConversationRequests(normalizedSessionId, 1000),
|
||||
listChatConversationMessages(normalizedSessionId, { limit: 1000 }),
|
||||
]);
|
||||
const isManagedShareRoomSession =
|
||||
tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
|
||||
const useInitialManagedShareRoomView = isManagedShareRoomSession && detailLevel === 'initial';
|
||||
const detailPage = useInitialManagedShareRoomView
|
||||
? await listChatConversationDetailPage(normalizedSessionId, { limit: 12 })
|
||||
: null;
|
||||
const [requests, messages] = detailPage
|
||||
? [detailPage.requests, detailPage.messages]
|
||||
: await Promise.all([
|
||||
listChatConversationRequests(normalizedSessionId, 1000),
|
||||
listChatConversationMessages(normalizedSessionId, { limit: 1000 }),
|
||||
]);
|
||||
const requestMap = new Map(requests.map((request) => [request.requestId.trim(), request] as const));
|
||||
const targetRequestId = options?.requestId?.trim() || tokenPayload.requestId.trim();
|
||||
const targetRequestFromStore = requestMap.get(targetRequestId) ?? null;
|
||||
const targetRequestFromStore = requestMap.get(targetRequestId) ?? await getChatConversationRequest(normalizedSessionId, targetRequestId);
|
||||
const placeholderTargetRequest = targetRequestFromStore ? null : buildManagedSharePlaceholderRequest(tokenPayload, conversation);
|
||||
const targetRequest = targetRequestFromStore ?? placeholderTargetRequest;
|
||||
|
||||
@@ -1143,13 +1254,13 @@ async function buildChatShareSnapshot(
|
||||
}
|
||||
|
||||
const childRequestIdsByParentRequestId = buildChildRequestIdsByParentRequestId(requests);
|
||||
const isManagedShareRoomSession =
|
||||
tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
|
||||
const isManagedShareRoomPlaceholder = isManagedShareRoomSession && !targetRequestFromStore;
|
||||
const rootRequestId = isManagedShareRoomPlaceholder
|
||||
? targetRequestId
|
||||
: resolveShareRootRequestId(targetRequestId, requestMap, tokenPayload.kind);
|
||||
const scopeRequestIds = isManagedShareRoomSession
|
||||
const scopeRequestIds = useInitialManagedShareRoomView
|
||||
? requests.map((request) => request.requestId.trim()).filter(Boolean)
|
||||
: isManagedShareRoomSession
|
||||
? requests.map((request) => request.requestId.trim()).filter(Boolean)
|
||||
: collectShareScopeRequestIds(rootRequestId, childRequestIdsByParentRequestId);
|
||||
const scopeRequestIdSet = new Set(scopeRequestIds);
|
||||
@@ -1158,9 +1269,11 @@ async function buildChatShareSnapshot(
|
||||
const linkedRequestId = message.clientRequestId?.trim() || '';
|
||||
return linkedRequestId ? scopeRequestIdSet.has(linkedRequestId) : false;
|
||||
});
|
||||
const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
|
||||
const activityLogs = useInitialManagedShareRoomView
|
||||
? []
|
||||
: await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
|
||||
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
|
||||
const roomRequestCounts = buildRoomRequestCounts(requests, messages);
|
||||
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
|
||||
|
||||
if (tokenPayload.kind === 'prompt' && !promptTarget) {
|
||||
return null;
|
||||
@@ -1176,6 +1289,7 @@ async function buildChatShareSnapshot(
|
||||
activityLogs,
|
||||
roomRequestCounts,
|
||||
promptTarget,
|
||||
detailLevel,
|
||||
} satisfies {
|
||||
conversation: Awaited<ReturnType<typeof getChatConversation>>;
|
||||
rootRequestId: string;
|
||||
@@ -1187,7 +1301,7 @@ async function buildChatShareSnapshot(
|
||||
roomRequestCounts: {
|
||||
processingCount: number;
|
||||
unansweredCount: number;
|
||||
};
|
||||
} | undefined;
|
||||
promptTarget:
|
||||
| {
|
||||
sourceMessageId: number;
|
||||
@@ -1195,6 +1309,7 @@ async function buildChatShareSnapshot(
|
||||
prompt: Extract<ListedChatConversationMessage['parts'][number], { type: 'prompt' }>;
|
||||
}
|
||||
| null;
|
||||
detailLevel: 'full' | 'initial';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1509,6 +1624,10 @@ function hasManagedShareAllowedApp(
|
||||
}
|
||||
|
||||
export async function registerChatRoutes(app: FastifyInstance) {
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (request, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
|
||||
app.addHook('onSend', async (request, reply, payload) => {
|
||||
if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) {
|
||||
applyChatApiNoStoreHeaders(reply);
|
||||
@@ -1756,6 +1875,11 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
title: z.string().trim().min(1).max(200),
|
||||
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
|
||||
seedMessage: z.string().trim().min(1).max(20000),
|
||||
linkedSessionId: z.string().trim().min(1).max(120).optional().nullable(),
|
||||
linkedRequestId: z.string().trim().min(1).max(120).optional().nullable(),
|
||||
linkedTitle: z.string().trim().max(200).optional().nullable(),
|
||||
linkedRequestPreview: z.string().trim().max(1000).optional().nullable(),
|
||||
linkedChatTypeLabel: z.string().trim().max(200).optional().nullable(),
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
@@ -1852,6 +1976,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
rootRequestId: requestId,
|
||||
isDefault: false,
|
||||
createdByClientId: clientId || null,
|
||||
linkContext: normalizeShareRoomLinkContext(payload),
|
||||
});
|
||||
|
||||
const room = await getChatShareTokenRoomMap(currentToken.id, sessionId);
|
||||
@@ -1872,6 +1997,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
contextLabel: payload.chatTypeLabel,
|
||||
contextDescription: null,
|
||||
notifyOffline: true,
|
||||
linkContext: normalizeShareRoomLinkContext(payload),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
@@ -2322,6 +2448,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}).parse(request.params ?? {});
|
||||
const query = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).optional(),
|
||||
view: z.enum(['full', 'initial']).optional(),
|
||||
}).parse(request.query ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
@@ -2361,6 +2488,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
|
||||
sessionId: activeRoom.sessionId,
|
||||
requestId: activeRoom.requestId,
|
||||
detailLevel: query.view === 'initial' ? 'initial' : 'full',
|
||||
});
|
||||
|
||||
if (!shareSnapshot) {
|
||||
@@ -2433,6 +2561,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
rooms: resolvedRoomContext.rooms,
|
||||
activeSessionId: activeRoom.sessionId,
|
||||
promptTarget: shareSnapshot.promptTarget,
|
||||
detailLevel: shareSnapshot.detailLevel,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -2750,15 +2879,16 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/attachments`, { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
|
||||
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/origin-reply`, async (request, reply) => {
|
||||
const params = z.object({
|
||||
token: z.string().trim().min(1).max(16000),
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
|
||||
fileName: z.string().trim().max(255).optional(),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
||||
sourceSessionId: z.string().trim().min(1).max(120),
|
||||
sourceRequestId: z.string().trim().min(1).max(120),
|
||||
text: z.string().trim().min(1).max(20000),
|
||||
mode: z.enum(['queue', 'direct']).optional(),
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
@@ -2781,6 +2911,109 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
|
||||
|
||||
if (unavailableMessage) {
|
||||
return reply.code(403).send({
|
||||
message: unavailableMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크에는 원 세션 답변 전송 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedSourceSessionId = payload.sourceSessionId.trim();
|
||||
const normalizedSourceRequestId = payload.sourceRequestId.trim();
|
||||
const allowedLinkTargets = resolvedRoomContext.rooms
|
||||
.map((room) => room.linkContext)
|
||||
.filter((item): item is ChatShareRoomLinkContext => item?.kind === 'linked-session');
|
||||
const matchedLinkTarget = allowedLinkTargets.find((item) =>
|
||||
item.sourceSessionId === normalizedSourceSessionId && item.sourceRequestId === normalizedSourceRequestId,
|
||||
);
|
||||
|
||||
if (!matchedLinkTarget) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유채팅에 연결된 원 세션만 답변 전송할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const targetRequest = await getChatConversationRequest(normalizedSourceSessionId, normalizedSourceRequestId);
|
||||
|
||||
if (!targetRequest) {
|
||||
return reply.code(404).send({
|
||||
message: '원 세션 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const targetConversation = await getChatConversation(normalizedSourceSessionId, null);
|
||||
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(
|
||||
normalizedSourceSessionId,
|
||||
payload.text,
|
||||
{
|
||||
mode: payload.mode === 'direct' ? 'direct' : 'queue',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
|
||||
parentRequestId: normalizedSourceRequestId,
|
||||
clientId: targetRequest.requesterClientId ?? targetConversation?.clientId ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
if (!queuedRequestId) {
|
||||
return reply.code(503).send({
|
||||
message: '원 세션 답변 전송을 시작하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (managedContext.managedResource) {
|
||||
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
|
||||
actorLabel: 'share-viewer',
|
||||
summary: '공유채팅에서 원 세션으로 답변을 전송했습니다.',
|
||||
detail: `${normalizedSourceSessionId}:${normalizedSourceRequestId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
queuedRequestId,
|
||||
};
|
||||
});
|
||||
|
||||
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/attachments`, { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
|
||||
const params = z.object({
|
||||
token: z.string().trim().min(1).max(16000),
|
||||
}).parse(request.params ?? {});
|
||||
const isBinaryRequest = isOctetStreamRequest(request.headers['content-type']);
|
||||
const payload = isBinaryRequest
|
||||
? parseChatAttachmentBinaryHeaders(request.headers, { allowOptionalSessionId: true })
|
||||
: chatAttachmentShareJsonBodySchema.parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
|
||||
if (!tokenPayload) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 링크가 유효하지 않습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedRoomContext = await resolveActiveManagedShareRoom({
|
||||
managedResource: managedContext.managedResource,
|
||||
tokenPayload,
|
||||
requestedSessionId: payload.sessionId ?? null,
|
||||
});
|
||||
|
||||
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
|
||||
sessionId: resolvedRoomContext.activeRoom.sessionId,
|
||||
requestId: resolvedRoomContext.activeRoom.requestId,
|
||||
@@ -2823,12 +3056,19 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await saveChatAttachmentFile({
|
||||
sessionId: resolvedRoomContext.activeRoom.sessionId,
|
||||
fileName: payload.fileName,
|
||||
mimeType: payload.mimeType,
|
||||
contentBase64: payload.contentBase64,
|
||||
});
|
||||
const saved = isBinaryRequest
|
||||
? await saveChatAttachmentBuffer({
|
||||
sessionId: resolvedRoomContext.activeRoom.sessionId,
|
||||
fileName: payload.fileName,
|
||||
mimeType: payload.mimeType,
|
||||
buffer: Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0),
|
||||
})
|
||||
: await saveChatAttachmentFile({
|
||||
sessionId: resolvedRoomContext.activeRoom.sessionId,
|
||||
fileName: payload.fileName,
|
||||
mimeType: payload.mimeType,
|
||||
contentBase64: chatAttachmentShareJsonBodySchema.parse(request.body ?? {}).contentBase64,
|
||||
});
|
||||
|
||||
if (!saved.ok) {
|
||||
return reply.code(saved.statusCode).send({
|
||||
@@ -3437,13 +3677,18 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
||||
fileName: z.string().trim().max(255).optional(),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
||||
}).parse(request.body ?? {});
|
||||
const saved = await saveChatAttachmentFile(payload);
|
||||
const isBinaryRequest = isOctetStreamRequest(request.headers['content-type']);
|
||||
const saved = isBinaryRequest
|
||||
? await (() => {
|
||||
const binaryPayload = parseChatAttachmentBinaryHeaders(request.headers);
|
||||
return saveChatAttachmentBuffer({
|
||||
sessionId: binaryPayload.sessionId ?? '',
|
||||
fileName: binaryPayload.fileName,
|
||||
mimeType: binaryPayload.mimeType,
|
||||
buffer: Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0),
|
||||
});
|
||||
})()
|
||||
: await saveChatAttachmentFile(chatAttachmentJsonBodySchema.parse(request.body ?? {}));
|
||||
|
||||
if (!saved.ok) {
|
||||
return reply.code(saved.statusCode).send({
|
||||
|
||||
Reference in New Issue
Block a user