import { createHmac, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { access, mkdir, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; import { getSharedResourceTokenDetailBySharePath, recordSharedResourceTokenUsage, resolveSharedResourceTokenEffectiveExpiresAt, type SharedResourceTokenSettingSnapshot, upsertSharedResourceToken, validateSharedResourceAccessPinBySharePath, } from '../services/shared-resource-token-service.js'; import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRuntimeController, } from '../services/chat-service.js'; import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js'; import { assignSharedResourceTokenToRequests, appendChatConversationActivityLine, appendChatConversationMessage, buildChatPromptTargetSignature, CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH, cancelUnansweredChatConversationRequest, clearChatConversationData, createChatConversation, deleteUnansweredChatConversationRequest, deleteChatConversation, ensureChatConversationTables, getChatConversation, getChatConversationRequest, MANAGED_CHAT_SHARE_SESSION_PREFIX, listChatConversationActivityLogsByRequestIds, listChatConversationMessages, listChatConversationRequests, listChatSourceChangeSnapshots, listChatConversationDetailPage, listChatConversations, markChatConversationRequestManualCompletion, ChatConversationManualCompletionBlockedError, markChatConversationResponsesRead, persistChatConversationPromptSelection, upsertChatConversationRequest, updateChatConversationContext, hasPendingAttentionVerificationRequest, } from '../services/chat-room-service.js'; import { archiveChatShareTokenRoomMap, ensureDefaultChatShareTokenRoomMap, getChatShareTokenRoomMap, resolveChatShareTokenRoomSessionIds, upsertChatShareTokenRoomMap, type ChatShareRoomLinkContext, type ChatShareTokenRoomMapItem, } from '../services/chat-share-room-map-service.js'; import { chatRuntimeService } from '../services/chat-runtime-service.js'; 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_FILE_SIZE_LIMIT = 300 * 1024 * 1024; const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 450 * 1024 * 1024; const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/'; const CHAT_LEGACY_PUBLIC_ROUTE_PREFIX = '/public/.codex_chat/'; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; const CHAT_SHARE_ROUTE_PREFIX = '/api/chat/shares'; const CHAT_SHARE_TOKEN_VERSION = 1; const RESOURCE_MANAGER_ROOT_PREFIX = 'resource/'; type ChatShareKind = 'request-bundle' | 'inquiry-message' | 'prompt'; type ChatShareTokenPayload = { version: number; kind: ChatShareKind; sessionId: string; requestId: string; tokenSettingId: string; tokenSettingName: string; tokenSettingDefaultExpiresInMinutes: number; tokenSettingAllowedAppIds: string[]; tokenSettingMaxTokensPer30Days: number; tokenSettingMaxTokensPer7Days: number; tokenSettingMaxTokensPer5Hours: number; tokenSettingOneTimeTokenLimit: number; managedResourceTokenId?: string; sourceMessageId?: number; promptIndex?: number; promptSignature?: string; }; const chatPromptContextRefSchema = z .object({ key: z.literal('prompt_parent_question'), promptTitle: z.string().trim().min(1).max(500), promptDescription: z.string().trim().max(1000).optional().nullable(), parentQuestionText: z.string().trim().max(1000).optional().nullable(), }) .optional() .nullable(); const chatComposerAttachmentSchema = z.object({ id: z.string().trim().min(1).max(240), name: z.string().trim().min(1).max(500), path: z.string().trim().min(1).max(4000), publicUrl: z.string().trim().min(1).max(4000), size: z.number().finite().min(0).max(CHAT_ATTACHMENT_FILE_SIZE_LIMIT), mimeType: z.string().trim().min(1).max(255), }); async function findExistingActivePromptFollowupRequest( sessionId: string, parentRequestId: string, followupText: string, ) { const normalizedSessionId = sessionId.trim(); const normalizedParentRequestId = parentRequestId.trim(); const normalizedFollowupText = followupText.trim(); if (!normalizedSessionId || !normalizedParentRequestId || !normalizedFollowupText) { return null; } const requests = await listChatConversationRequests(normalizedSessionId, 1000); return requests.find((request) => request.requestOrigin === 'prompt' && (request.parentRequestId?.trim() || '') === normalizedParentRequestId && request.userText.trim() === normalizedFollowupText && (request.status === 'queued' || request.status === 'accepted' || request.status === 'started') ) ?? null; } export function resolvePromptFollowupMode(mode?: 'queue' | 'direct' | null) { return mode === 'direct' ? 'direct' : 'queue'; } function encodeBase64Url(value: string) { return Buffer.from(value, 'utf-8').toString('base64url'); } function decodeBase64Url(value: string) { return Buffer.from(value, 'base64url').toString('utf-8'); } function signChatSharePayload(encodedPayload: string) { return createHmac('sha256', env.SERVER_COMMAND_ACCESS_TOKEN).update(encodedPayload).digest('base64url'); } function createChatShareToken(payload: ChatShareTokenPayload) { const encodedPayload = encodeBase64Url(JSON.stringify(payload)); const signature = signChatSharePayload(encodedPayload); return `${encodedPayload}.${signature}`; } function parseChatShareToken(token: string) { const normalizedToken = token.trim(); if (!normalizedToken) { return null; } const delimiterIndex = normalizedToken.lastIndexOf('.'); if (delimiterIndex <= 0 || delimiterIndex >= normalizedToken.length - 1) { return null; } const encodedPayload = normalizedToken.slice(0, delimiterIndex); const signature = normalizedToken.slice(delimiterIndex + 1); const expectedSignature = signChatSharePayload(encodedPayload); if (signature !== expectedSignature) { return null; } try { const parsed = JSON.parse(decodeBase64Url(encodedPayload)) as Partial; if ( parsed.version !== CHAT_SHARE_TOKEN_VERSION || (parsed.kind !== 'request-bundle' && parsed.kind !== 'inquiry-message' && parsed.kind !== 'prompt') || typeof parsed.sessionId !== 'string' || typeof parsed.requestId !== 'string' || typeof parsed.tokenSettingId !== 'string' || typeof parsed.tokenSettingName !== 'string' ) { return null; } return { version: CHAT_SHARE_TOKEN_VERSION, kind: parsed.kind, sessionId: parsed.sessionId.trim(), requestId: parsed.requestId.trim(), tokenSettingId: parsed.tokenSettingId.trim(), tokenSettingName: parsed.tokenSettingName.trim(), tokenSettingDefaultExpiresInMinutes: typeof parsed.tokenSettingDefaultExpiresInMinutes === 'number' ? parsed.tokenSettingDefaultExpiresInMinutes : 0, tokenSettingAllowedAppIds: Array.isArray(parsed.tokenSettingAllowedAppIds) ? parsed.tokenSettingAllowedAppIds.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean) : [], tokenSettingMaxTokensPer30Days: typeof parsed.tokenSettingMaxTokensPer30Days === 'number' ? parsed.tokenSettingMaxTokensPer30Days : 0, tokenSettingMaxTokensPer7Days: typeof parsed.tokenSettingMaxTokensPer7Days === 'number' ? parsed.tokenSettingMaxTokensPer7Days : 0, tokenSettingMaxTokensPer5Hours: typeof parsed.tokenSettingMaxTokensPer5Hours === 'number' ? parsed.tokenSettingMaxTokensPer5Hours : 0, tokenSettingOneTimeTokenLimit: typeof parsed.tokenSettingOneTimeTokenLimit === 'number' ? parsed.tokenSettingOneTimeTokenLimit : 0, managedResourceTokenId: typeof parsed.managedResourceTokenId === 'string' ? parsed.managedResourceTokenId.trim() : undefined, sourceMessageId: typeof parsed.sourceMessageId === 'number' ? parsed.sourceMessageId : undefined, promptIndex: typeof parsed.promptIndex === 'number' ? parsed.promptIndex : undefined, promptSignature: typeof parsed.promptSignature === 'string' ? parsed.promptSignature.trim() : undefined, } satisfies ChatShareTokenPayload; } catch { return null; } } function resolveChatSharePath(token: string) { return `/chat/share/${encodeURIComponent(token)}`; } function resolveChatShareTokenSettingSnapshot(tokenPayload: ChatShareTokenPayload): SharedResourceTokenSettingSnapshot { return { id: tokenPayload.tokenSettingId, name: tokenPayload.tokenSettingName, defaultExpiresInMinutes: tokenPayload.tokenSettingDefaultExpiresInMinutes, maxTokensPer30Days: tokenPayload.tokenSettingMaxTokensPer30Days, maxTokensPer7Days: tokenPayload.tokenSettingMaxTokensPer7Days, maxTokensPer5Hours: tokenPayload.tokenSettingMaxTokensPer5Hours, oneTimeTokenLimit: tokenPayload.tokenSettingOneTimeTokenLimit, allowedAppIds: tokenPayload.tokenSettingAllowedAppIds, }; } type ChatShareResolvedRoom = { sessionId: string; requestId: string; isDefault: boolean; sortOrder: number; title: string; requestBadgeLabel: string | null; chatTypeId: string | null; lastChatTypeId: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; linkContext: ChatShareRoomLinkContext | null; createdAt: string | null; updatedAt: string | null; }; function mapResolvedShareRoomItem(room: ChatShareTokenRoomMapItem): ChatShareResolvedRoom { return { sessionId: room.sessionId, requestId: room.rootRequestId, isDefault: room.isDefault, sortOrder: room.sortOrder, title: room.title, requestBadgeLabel: room.requestBadgeLabel, chatTypeId: room.chatTypeId, lastChatTypeId: room.lastChatTypeId, contextLabel: room.contextLabel, contextDescription: room.contextDescription, notifyOffline: room.notifyOffline, linkContext: room.linkContext, createdAt: room.createdAt, updatedAt: room.conversationUpdatedAt ?? room.updatedAt, }; } async function resolveManagedShareRooms(args: { managedResource: | Awaited>['managedResource'] | null; tokenPayload: ChatShareTokenPayload; }) { const managedResourceTokenId = args.managedResource?.token.id?.trim() || ''; if (!managedResourceTokenId || args.tokenPayload.kind !== 'request-bundle') { return [ { sessionId: args.tokenPayload.sessionId, requestId: args.tokenPayload.requestId, isDefault: true, sortOrder: 0, title: '공유 채팅방', requestBadgeLabel: null, chatTypeId: null, lastChatTypeId: null, contextLabel: null, contextDescription: null, notifyOffline: false, linkContext: null, createdAt: null, updatedAt: null, } satisfies ChatShareResolvedRoom, ]; } const ensuredRooms = await ensureDefaultChatShareTokenRoomMap({ tokenId: managedResourceTokenId, sessionId: args.tokenPayload.sessionId, rootRequestId: args.tokenPayload.requestId, createdByClientId: null, }); if (ensuredRooms.length === 0) { return [ { sessionId: args.tokenPayload.sessionId, requestId: args.tokenPayload.requestId, isDefault: true, sortOrder: 0, title: '공유 채팅방', requestBadgeLabel: null, chatTypeId: null, lastChatTypeId: null, contextLabel: null, contextDescription: null, notifyOffline: false, linkContext: null, createdAt: null, updatedAt: null, } satisfies ChatShareResolvedRoom, ]; } return ensuredRooms.map((room) => mapResolvedShareRoomItem(room)); } async function resolveActiveManagedShareRoom(args: { managedResource: | Awaited>['managedResource'] | null; tokenPayload: ChatShareTokenPayload; requestedSessionId?: string | null; }) { const rooms = await resolveManagedShareRooms({ managedResource: args.managedResource, tokenPayload: args.tokenPayload, }); const requestedSessionId = args.requestedSessionId?.trim() || ''; const requestedRoom = requestedSessionId ? rooms.find((room) => room.sessionId === requestedSessionId) ?? null : null; const defaultRoom = rooms.find((room) => room.isDefault) ?? rooms[0] ?? null; const activeRoom = requestedRoom ?? defaultRoom; return { rooms, activeRoom, requestedRoomMatched: !requestedSessionId || Boolean(requestedRoom), }; } function createManagedChatShareTokenId() { return `chat_share_${randomUUID().replace(/-/g, '').slice(0, 20)}`; } function createManagedChatShareSessionId() { return `chat-share-room-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; } function createManagedChatShareRequestId() { return `share-room-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; } function createManagedChatShareMessageIds() { const baseId = Date.now() * 1000 + Math.floor(Math.random() * 100); return { userMessageId: baseId, }; } 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) { return left.id - right.id; } return left.timestamp.localeCompare(right.timestamp); }); } function sortShareRequests(requests: ListedChatConversationRequest[]) { return [...requests].sort((left, right) => { const createdAtDiff = left.createdAt.localeCompare(right.createdAt); if (createdAtDiff !== 0) { return createdAtDiff; } return left.requestId.localeCompare(right.requestId); }); } async function materializeManagedShareConversation(args: { shareSnapshot: NonNullable>>; managedResourceTokenId: string; ownerClientId: string | null; shareTitle: string; }) { const { shareSnapshot, managedResourceTokenId, ownerClientId, shareTitle } = args; const sessionId = createManagedChatShareSessionId(); const requestIdSet = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean)); const sortedRequests = sortShareRequests(shareSnapshot.requests); const sortedMessages = sortShareMessages(shareSnapshot.messages); const sourceConversation = shareSnapshot.conversation; await createChatConversation({ sessionId, clientId: ownerClientId, title: shareTitle, draftText: '', requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null, codexModel: sourceConversation?.codexModel ?? null, chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null, lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null, generalSectionName: sourceConversation?.generalSectionName ?? null, contextLabel: sourceConversation?.contextLabel ?? null, contextDescription: sourceConversation?.contextDescription ?? null, notifyOffline: true, }); for (const message of sortedMessages) { await appendChatConversationMessage( { sessionId, clientId: ownerClientId, title: shareTitle, requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null, codexModel: sourceConversation?.codexModel ?? null, chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null, lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null, generalSectionName: sourceConversation?.generalSectionName ?? null, contextLabel: sourceConversation?.contextLabel ?? null, contextDescription: sourceConversation?.contextDescription ?? null, notifyOffline: true, }, { sessionId, messageId: message.id, author: message.author, text: message.text, timestamp: message.timestamp, clientRequestId: message.clientRequestId?.trim() || null, parts: message.parts ?? [], }, ); } for (const request of sortedRequests) { const normalizedParentRequestId = request.parentRequestId?.trim() || ''; await upsertChatConversationRequest(sessionId, { requestId: request.requestId, requesterClientId: request.requesterClientId ?? ownerClientId, chatTypeId: request.chatTypeId ?? sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null, chatTypeLabel: request.chatTypeLabel ?? sourceConversation?.contextLabel ?? null, requestOrigin: request.requestOrigin, sharedResourceTokenId: managedResourceTokenId, parentRequestId: normalizedParentRequestId && requestIdSet.has(normalizedParentRequestId) ? normalizedParentRequestId : null, status: request.status, statusMessage: request.statusMessage, userMessageId: request.userMessageId, userText: request.userText, responseMessageId: request.responseMessageId, responseText: request.responseText, usageSnapshot: request.usageSnapshot, totalTokens: request.totalTokens, }); } for (const activityLog of shareSnapshot.activityLogs) { let lineNo = 1; for (const line of activityLog.lines) { await appendChatConversationActivityLine(sessionId, activityLog.requestId, line, lineNo); lineNo += 1; } } await assignSharedResourceTokenToRequests( sessionId, sortedRequests.map((item) => item.requestId), managedResourceTokenId, ); return { sessionId, requestId: sortedRequests.find((item) => item.requestId.trim() === shareSnapshot.targetRequest.requestId.trim())?.requestId ?? shareSnapshot.targetRequest.requestId, }; } export function resolveStaticContentType(filePath: string) { const extension = path.extname(filePath).toLowerCase(); switch (extension) { case '.ts': case '.tsx': case '.js': case '.jsx': case '.mjs': case '.cjs': case '.json': case '.css': case '.txt': case '.diff': return 'text/plain; charset=utf-8'; case '.html': case '.htm': return 'text/html; charset=utf-8'; case '.md': case '.markdown': return 'text/markdown; charset=utf-8'; case '.svg': return 'image/svg+xml'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.gif': return 'image/gif'; case '.webp': return 'image/webp'; case '.ico': return 'image/x-icon'; case '.pdf': return 'application/pdf'; default: return 'application/octet-stream'; } } function buildChatResourcePublicUrl(relativePath: string) { const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, ''); return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/')}`; } function normalizeChatResourceWildcard(wildcard: string) { const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, ''); if (!cleaned) { return ''; } if (cleaned.startsWith('.codex_chat/')) { return cleaned; } if (cleaned === RESOURCE_MANAGER_ROOT_PREFIX.slice(0, -1) || cleaned.startsWith(RESOURCE_MANAGER_ROOT_PREFIX)) { return cleaned; } return path.posix.join('.codex_chat', cleaned); } async function serveChatPublicResource( repoPath: string, wildcard: string, reply: FastifyReply, ) { const requestedRelativePath = normalizeChatResourceWildcard(wildcard); if (!requestedRelativePath) { return reply.code(404).send({ message: '채팅 리소스를 찾을 수 없습니다.', }); } const publicRoot = path.join(repoPath, 'public'); const absolutePath = path.resolve(publicRoot, requestedRelativePath); if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) { return reply.code(403).send({ message: '허용되지 않은 경로입니다.', }); } try { await access(absolutePath); const fileStat = await stat(absolutePath); if (!fileStat.isFile()) { return reply.code(404).send({ message: '채팅 리소스를 찾을 수 없습니다.', }); } } catch { return reply.code(404).send({ message: '채팅 리소스를 찾을 수 없습니다.', }); } reply.header('Cache-Control', 'no-store'); reply.type(resolveStaticContentType(absolutePath)); return reply.send(createReadStream(absolutePath)); } async function serveChatSharedResource( repoPath: string, wildcard: string, reply: FastifyReply, ) { const requestedRelativePath = normalizeChatResourceWildcard(wildcard); const resourceManagerPrefix = `${RESOURCE_MANAGER_ROOT_PREFIX}`; if (requestedRelativePath.startsWith(resourceManagerPrefix)) { const resourceManagerRelativePath = requestedRelativePath.slice(resourceManagerPrefix.length).replace(/^\/+/, ''); if (!resourceManagerRelativePath) { return reply.code(404).send({ message: '공유 리소스를 찾을 수 없습니다.', }); } try { const preview = await openResourceManagerPreviewStream(repoPath, resourceManagerRelativePath); reply.header('Cache-Control', 'no-store'); reply.header('Content-Length', String(preview.size)); reply.type(preview.contentType); return reply.send(preview.createStream()); } catch (error) { const statusCode = typeof error === 'object' && error && 'statusCode' in error && typeof error.statusCode === 'number' ? error.statusCode : 500; const message = error instanceof Error && error.message.trim() ? error.message : '공유 리소스를 불러오지 못했습니다.'; return reply.code(statusCode).send({ message, }); } } return serveChatPublicResource(repoPath, requestedRelativePath, reply); } function sanitizeChatAttachmentFileName(fileName: string) { const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' '); const compact = normalized || 'attachment'; return compact.length > 120 ? compact.slice(-120) : compact; } function resolveAttachmentFallbackExtension(mimeType: string | undefined) { const normalizedMimeType = String(mimeType ?? '').trim().toLowerCase(); switch (normalizedMimeType) { case 'application/pdf': return 'pdf'; case 'application/zip': return 'zip'; case 'image/bmp': return 'bmp'; case 'image/gif': return 'gif'; case 'image/heic': return 'heic'; case 'image/heif': return 'heif'; case 'image/jpeg': return 'jpg'; case 'image/png': return 'png'; case 'image/tiff': return 'tiff'; case 'image/webp': return 'webp'; default: return 'bin'; } } function resolveChatAttachmentDisplayFileName(fileName: string | undefined, mimeType: string | undefined) { const normalizedFileName = String(fileName ?? '').trim(); if (normalizedFileName) { return normalizedFileName; } const extension = resolveAttachmentFallbackExtension(mimeType); const baseName = String(mimeType ?? '').trim().toLowerCase().startsWith('image/') ? 'pasted-image' : 'attachment'; return `${baseName}-${Date.now().toString(36)}.${extension}`; } function resolveChatAttachmentRepoPath() { return resolveMainProjectRoot(); } async function saveChatAttachmentFile(args: { sessionId: string; fileName?: string; mimeType?: string; 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 { ok: false as const, statusCode: 400, message: '업로드할 파일 내용을 찾지 못했습니다.', }; } if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) { return { ok: false as const, statusCode: 413, message: '첨부 파일은 300MB 이하만 업로드할 수 있습니다.', }; } const displayFileName = resolveChatAttachmentDisplayFileName(args.fileName, args.mimeType); const safeFileName = sanitizeChatAttachmentFileName(displayFileName); const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; const relativePath = path.posix.join( 'public', '.codex_chat', args.sessionId, 'resource', 'uploads', `${fileToken}-${safeFileName}`, ); const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/')); await ensureChatSessionResourceDirectories(resolveChatAttachmentRepoPath(), args.sessionId); await writeFile(absolutePath, buffer); return { ok: true as const, item: { id: randomUUID(), name: displayFileName, path: relativePath, publicUrl: buildChatResourcePublicUrl(relativePath), size: buffer.byteLength, mimeType: args.mimeType?.trim() || 'application/octet-stream', }, }; } 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, 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 }) { const raw = request.headers['x-client-id']; return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim(); } function canViewAllConversations(request: { headers: Record }) { return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined); } function applyChatApiNoStoreHeaders(reply: FastifyReply) { reply.header('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate'); reply.header('Pragma', 'no-cache'); reply.header('Expires', '0'); reply.header('Surrogate-Control', 'no-store'); } type ListedChatConversationRequest = Awaited>[number]; type ListedChatConversationMessage = Awaited>[number]; type ListedChatConversationActivityLog = Awaited>[number]; function resolveShareRootRequestId( requestId: string, requestMap: Map, kind: ChatShareKind, ) { const normalizedRequestId = requestId.trim(); if (!normalizedRequestId || kind !== 'request-bundle') { return normalizedRequestId; } const visitedRequestIds = new Set(); let currentRequestId = normalizedRequestId; while (currentRequestId && !visitedRequestIds.has(currentRequestId)) { visitedRequestIds.add(currentRequestId); const currentRequest = requestMap.get(currentRequestId); const parentRequestId = currentRequest?.parentRequestId?.trim() || ''; if (!parentRequestId || !requestMap.has(parentRequestId)) { break; } currentRequestId = parentRequestId; } return currentRequestId; } function buildChildRequestIdsByParentRequestId(requests: ListedChatConversationRequest[]) { const nextMap = new Map(); requests.forEach((request) => { const parentRequestId = request.parentRequestId?.trim() || ''; const requestId = request.requestId.trim(); if (!parentRequestId || !requestId) { return; } const currentChildRequestIds = nextMap.get(parentRequestId) ?? []; currentChildRequestIds.push(requestId); nextMap.set(parentRequestId, currentChildRequestIds); }); return nextMap; } function collectShareScopeRequestIds( rootRequestId: string, childRequestIdsByParentRequestId: Map, ) { const normalizedRootRequestId = rootRequestId.trim(); if (!normalizedRootRequestId) { return []; } const orderedRequestIds: string[] = []; const pendingRequestIds = [normalizedRootRequestId]; const visitedRequestIds = new Set(); while (pendingRequestIds.length > 0) { const currentRequestId = pendingRequestIds.shift()?.trim() || ''; if (!currentRequestId || visitedRequestIds.has(currentRequestId)) { continue; } visitedRequestIds.add(currentRequestId); orderedRequestIds.push(currentRequestId); (childRequestIdsByParentRequestId.get(currentRequestId) ?? []).forEach((childRequestId) => { if (!visitedRequestIds.has(childRequestId)) { pendingRequestIds.push(childRequestId); } }); } return orderedRequestIds; } function resolvePromptTarget( messages: ListedChatConversationMessage[], tokenPayload: ChatShareTokenPayload, ) { const preferredMessage = tokenPayload.sourceMessageId ? messages.find((message) => message.id === tokenPayload.sourceMessageId) : null; const candidateMessages = preferredMessage ? [preferredMessage, ...messages.filter((message) => message.id !== preferredMessage.id)] : messages; for (const message of candidateMessages) { const promptParts: Array<{ index: number; part: Extract[number], { type: 'prompt' }>; }> = (message.parts ?? []).flatMap((part: NonNullable[number], index: number) => part.type === 'prompt' ? [{ index, part }] : [], ); const directMatch = tokenPayload.promptIndex != null ? promptParts[tokenPayload.promptIndex] ?? null : null; if ( directMatch && tokenPayload.promptSignature && buildChatPromptTargetSignature(directMatch.part) === tokenPayload.promptSignature ) { return { sourceMessageId: message.id, promptIndex: directMatch.index, prompt: directMatch.part, }; } const matchedPrompt = promptParts.find(({ part }) => tokenPayload.promptSignature ? buildChatPromptTargetSignature(part) === tokenPayload.promptSignature : false, ); if (matchedPrompt) { return { sourceMessageId: message.id, promptIndex: matchedPrompt.index, prompt: matchedPrompt.part, }; } } return null; } function isPromptPartResolved( part: Extract[number], { type: 'prompt' }>, ) { return part.readOnly === true || part.resolvedBy != null; } function hasUnresolvedPromptPart(message: ListedChatConversationMessage) { const promptParts = (message.parts ?? []).filter( ( part: NonNullable[number], ): part is Extract[number], { type: 'prompt' }> => part.type === 'prompt', ); return promptParts.some( (part: Extract[number], { type: 'prompt' }>) => !isPromptPartResolved(part), ); } function hasPendingPromptRequest( request: ListedChatConversationRequest, relatedMessages: ListedChatConversationMessage[], promptSubmittedCount = 0, ) { if (request.manualPromptCompletedAt) { return false; } const unresolvedPromptCount = relatedMessages.reduce((count, message) => { if (message.author !== 'codex' && message.author !== 'system') { return count; } const promptParts = (message.parts ?? []).filter( ( part: NonNullable[number], ): part is Extract[number], { type: 'prompt' }> => part.type === 'prompt', ); return count + promptParts.filter( (part: Extract[number], { type: 'prompt' }>) => !isPromptPartResolved(part), ).length; }, 0); if (unresolvedPromptCount === 0) { return false; } return unresolvedPromptCount > Math.max(0, promptSubmittedCount); } function hasVerificationTargetRequest( request: ListedChatConversationRequest, relatedMessages: ListedChatConversationMessage[], ) { return hasPendingAttentionVerificationRequest(request, relatedMessages); } function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) { const nextMap = new Map(); requests.forEach((request) => { const parentRequestId = request.parentRequestId?.trim() || ''; if (!parentRequestId) { return; } nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1); }); return nextMap; } function buildPromptFollowupCountMap(requests: ListedChatConversationRequest[]) { const nextMap = new Map(); requests.forEach((request) => { if (request.requestOrigin !== 'prompt') { return; } const parentRequestId = request.parentRequestId?.trim() || ''; if (!parentRequestId) { return; } nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1); }); return nextMap; } function isPromptFollowupRoomRequest(request: ListedChatConversationRequest) { return request.requestOrigin === 'prompt'; } function isRequestInFlight(status: ListedChatConversationRequest['status']) { return status === 'accepted' || status === 'queued' || status === 'started'; } function isPendingCompletionRoomRequest( request: ListedChatConversationRequest, relatedMessages: ListedChatConversationMessage[], childRequestCountByParentId?: Map, promptFollowupCountByParentId?: Map, ) { if (isPromptFollowupRoomRequest(request)) { return false; } if (isRequestInFlight(request.status)) { return true; } if (hasPendingPromptRequest(request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0)) { return true; } if (request.manualPromptCompletedAt) { return false; } if ((childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0) { return false; } if (!hasVerificationTargetRequest(request, relatedMessages)) { return false; } return !request.manualVerificationCompletedAt; } function buildRoomRequestCounts( requests: ListedChatConversationRequest[], messages: ListedChatConversationMessage[], ) { const requestMessagesById = new Map(); const childRequestCountByParentId = buildChildRequestCountMap(requests); const promptFollowupCountByParentId = buildPromptFollowupCountMap(requests); messages.forEach((message) => { const requestId = message.clientRequestId?.trim() || ''; if (!requestId) { return; } const current = requestMessagesById.get(requestId) ?? []; current.push(message); requestMessagesById.set(requestId, current); }); const processingCount = requests.filter( (request) => !isPromptFollowupRoomRequest(request) && isRequestInFlight(request.status), ).length; const unansweredCount = requests.filter((request) => isPendingCompletionRoomRequest( request, requestMessagesById.get(request.requestId.trim()) ?? [], childRequestCountByParentId, promptFollowupCountByParentId, ), ).length; return { processingCount, unansweredCount, }; } function buildManagedSharePlaceholderRequest(tokenPayload: ChatShareTokenPayload, conversation: Awaited>) { const normalizedSessionId = tokenPayload.sessionId.trim(); const normalizedRequestId = tokenPayload.requestId.trim(); if ( tokenPayload.kind !== 'request-bundle' || !normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX) || !normalizedRequestId ) { return null; } const createdAt = conversation?.createdAt?.trim() || new Date().toISOString(); const updatedAt = conversation?.updatedAt?.trim() || createdAt; const chatTypeLabel = conversation?.contextLabel?.trim() || '기본처리'; return { sessionId: normalizedSessionId, requestId: normalizedRequestId, requesterClientId: conversation?.clientId?.trim() || null, chatTypeId: conversation?.chatTypeId?.trim() || conversation?.lastChatTypeId?.trim() || null, chatTypeLabel, requestOrigin: 'composer', sharedResourceTokenId: tokenPayload.managedResourceTokenId?.trim() || null, parentRequestId: null, promptContextRef: null, status: 'completed', statusMessage: '공유 채팅방 시작 요청을 복원했습니다.', retryCount: 0, userMessageId: null, userText: '', responseMessageId: null, responseText: '', usageSnapshot: null, totalTokens: null, hasResponse: false, canDelete: false, manualPromptCompletedAt: null, manualVerificationCompletedAt: null, createdAt, updatedAt, answeredAt: null, terminalAt: createdAt, } satisfies ListedChatConversationRequest; } async function buildChatShareSnapshot( tokenPayload: ChatShareTokenPayload, 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 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, includeActivityLogs: false, includePagination: false, }) : 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) ?? await getChatConversationRequest(normalizedSessionId, targetRequestId); const placeholderTargetRequest = targetRequestFromStore ? null : buildManagedSharePlaceholderRequest(tokenPayload, conversation); const targetRequest = targetRequestFromStore ?? placeholderTargetRequest; if (!targetRequest) { return null; } const childRequestIdsByParentRequestId = buildChildRequestIdsByParentRequestId(requests); const isManagedShareRoomPlaceholder = isManagedShareRoomSession && !targetRequestFromStore; const rootRequestId = isManagedShareRoomPlaceholder ? targetRequestId : resolveShareRootRequestId(targetRequestId, requestMap, tokenPayload.kind); 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); const scopedRequests = requests.filter((request) => scopeRequestIdSet.has(request.requestId.trim())); const scopedMessages = messages.filter((message: ListedChatConversationMessage) => { const linkedRequestId = message.clientRequestId?.trim() || ''; return linkedRequestId ? scopeRequestIdSet.has(linkedRequestId) : false; }); const activityLogs = useInitialManagedShareRoomView ? [] : await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds); const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null; const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages); const oldestLoadedMessageId = detailPage?.oldestLoadedMessageId ?? null; const hasOlderMessages = detailPage?.hasOlderMessages ?? false; if (tokenPayload.kind === 'prompt' && !promptTarget) { return null; } return { conversation, rootRequestId, targetRequest, isRecoveredManagedShareRoom: isManagedShareRoomPlaceholder, requests: scopedRequests, messages: scopedMessages, activityLogs, roomRequestCounts, oldestLoadedMessageId, hasOlderMessages, promptTarget, detailLevel, } satisfies { conversation: Awaited>; rootRequestId: string; targetRequest: ListedChatConversationRequest; isRecoveredManagedShareRoom: boolean; requests: ListedChatConversationRequest[]; messages: ListedChatConversationMessage[]; activityLogs: ListedChatConversationActivityLog[]; roomRequestCounts: { processingCount: number; unansweredCount: number; } | undefined; oldestLoadedMessageId: number | null; hasOlderMessages: boolean; promptTarget: | { sourceMessageId: number; promptIndex: number; prompt: Extract; } | null; detailLevel: 'full' | 'initial'; }; } function resolveRecoveredShareParentRequestId( shareSnapshot: Awaited>, requestedParentRequestId: string | null | undefined, fallbackRequestIds: Array = [], ) { if (!shareSnapshot) { return null; } const allowedRequestIds = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean)); const normalizedRequestedParentRequestId = requestedParentRequestId?.trim() || ''; if (normalizedRequestedParentRequestId && allowedRequestIds.has(normalizedRequestedParentRequestId)) { return normalizedRequestedParentRequestId; } const normalizedFallbackRequestIds = fallbackRequestIds.map((value) => value?.trim() || '').filter(Boolean); for (const fallbackRequestId of normalizedFallbackRequestIds) { if (allowedRequestIds.has(fallbackRequestId)) { return fallbackRequestId; } } if (!shareSnapshot.isRecoveredManagedShareRoom) { return normalizedRequestedParentRequestId || null; } return shareSnapshot.requests[shareSnapshot.requests.length - 1]?.requestId?.trim() || null; } function resolveRequestUsageAt(request: ListedChatConversationRequest) { const candidates = [request.answeredAt, request.terminalAt, request.updatedAt, request.createdAt]; for (const candidate of candidates) { const normalized = candidate?.trim(); if (!normalized) { continue; } const parsed = Date.parse(normalized); if (Number.isFinite(parsed)) { return parsed; } } return 0; } function resolveRequestUsageTokens(request: ListedChatConversationRequest) { const total = request.usageSnapshot?.tokenTotals?.total ?? request.usageSnapshot?.totalTokens ?? request.totalTokens ?? 0; return Math.max(0, Math.round(Number(total) || 0)); } type ShareTokenLimitSetting = Pick; function resolvePeriodTokenLimit(setting: ShareTokenLimitSetting, periodKey: '7d' | '5h') { if (periodKey === '7d') { return setting.maxTokensPer7Days; } return setting.maxTokensPer5Hours; } function resolveTokenUsageInWindow(requests: ListedChatConversationRequest[], nowMs: number, windowMs: number) { const threshold = nowMs - windowMs; return requests.reduce((sum, request) => { if (resolveRequestUsageAt(request) < threshold) { return sum; } return sum + resolveRequestUsageTokens(request); }, 0); } function resolveShareBlockedReason(requests: ListedChatConversationRequest[], tokenSetting: ShareTokenLimitSetting) { const nowMs = Date.now(); const periodWindows: Array<{ key: '7d' | '5h'; label: string; windowMs: number }> = [ { key: '7d', label: '7일', windowMs: 7 * 24 * 60 * 60 * 1000 }, { key: '5h', label: '5시간', windowMs: 5 * 60 * 60 * 1000 }, ]; for (const period of periodWindows) { const limit = resolvePeriodTokenLimit(tokenSetting, period.key); if (limit <= 0) { continue; } const used = resolveTokenUsageInWindow(requests, nowMs, period.windowMs); if (used >= limit) { return `${period.label} 사용 가능 토큰 한도(${limit.toLocaleString('ko-KR')})를 모두 사용했습니다.`; } } return null; } async function resolveManagedChatShareContext(token: string) { const sharePath = resolveChatSharePath(token); const managedResource = await getSharedResourceTokenDetailBySharePath(sharePath); return { sharePath, managedResource, }; } function buildManagedShareRoomRuntimeSnapshot( snapshot: ReturnType, sessionId: string, ) { const normalizedSessionId = sessionId.trim(); const running = snapshot.running.filter((item) => item.sessionId === normalizedSessionId); const queued = snapshot.queued.filter((item) => item.sessionId === normalizedSessionId); const sessions = snapshot.sessions.filter((item) => item.sessionId === normalizedSessionId); const recent = snapshot.recent.filter((item) => item.sessionId === normalizedSessionId); return { generatedAt: snapshot.generatedAt, runningCount: running.length, queuedCount: queued.length, sessionCount: sessions.length, running, queued, sessions, recent, }; } function resolveChatSharePayloadFromManagedResource( managedResource: | Awaited> | null | undefined, ) { const resourceContext = managedResource?.token.resourceContext; const sessionId = resourceContext?.sessionId?.trim() || ''; const requestId = resourceContext?.requestId?.trim() || ''; if (!resourceContext || !sessionId || !requestId) { return null; } return { version: CHAT_SHARE_TOKEN_VERSION, kind: resourceContext.kind ?? 'request-bundle', sessionId, requestId, tokenSettingId: managedResource?.token.tokenSettingSnapshot?.id ?? '', tokenSettingName: managedResource?.token.tokenSettingSnapshot?.name ?? '', tokenSettingDefaultExpiresInMinutes: managedResource?.token.tokenSettingSnapshot?.defaultExpiresInMinutes ?? 0, tokenSettingAllowedAppIds: managedResource?.token.allowedAppIds ?? managedResource?.token.tokenSettingSnapshot?.allowedAppIds ?? [], tokenSettingMaxTokensPer30Days: managedResource?.token.tokenSettingSnapshot?.maxTokensPer30Days ?? 0, tokenSettingMaxTokensPer7Days: managedResource?.token.tokenSettingSnapshot?.maxTokensPer7Days ?? 0, tokenSettingMaxTokensPer5Hours: managedResource?.token.tokenSettingSnapshot?.maxTokensPer5Hours ?? 0, tokenSettingOneTimeTokenLimit: managedResource?.token.tokenSettingSnapshot?.oneTimeTokenLimit ?? 0, managedResourceTokenId: managedResource?.token.id, sourceMessageId: resourceContext.sourceMessageId ?? undefined, promptIndex: resourceContext.promptIndex ?? undefined, promptSignature: resourceContext.promptSignature ?? undefined, } satisfies ChatShareTokenPayload; } function resolveManagedShareUnavailableMessage( managedResource: | { token: { enabled: boolean; revokedAt: string | null; expiresAt: string | null; createdAt: string; resourceType?: string | null; tokenSettingSnapshot?: SharedResourceTokenSettingSnapshot | null; linkedTokenSetting?: { defaultExpiresInMinutes: number; syncState: 'ok' | 'missing' | 'disabled' | 'chat-share-disallowed'; syncMessage: string | null; } | null; }; } | null | undefined, ) { if (!managedResource) { return null; } if (managedResource.token.revokedAt || managedResource.token.enabled === false) { return '이 공유 링크는 현재 비활성 또는 회수 상태입니다.'; } const effectiveExpiresAt = resolveSharedResourceTokenEffectiveExpiresAt( managedResource.token, managedResource.token.tokenSettingSnapshot, ); if (effectiveExpiresAt && Date.parse(effectiveExpiresAt) <= Date.now()) { return '이 공유 링크는 만료되었습니다.'; } if (managedResource.token.resourceType === 'chat-share') { return null; } return null; } function getRequestChatSharePin(request: { headers: Record; query?: unknown; }) { const pinHeader = request.headers['x-chat-share-pin']; const headerPin = Array.isArray(pinHeader) ? pinHeader[0] : pinHeader; if (typeof headerPin === 'string' && headerPin.trim()) { return headerPin.trim(); } const queryRecord = request.query && typeof request.query === 'object' ? (request.query as Record) : null; const queryPin = queryRecord && typeof queryRecord.sharePin === 'string' ? queryRecord.sharePin : ''; return queryPin.trim(); } function getRequestClientId(request: { headers: Record; query?: unknown; }) { const headerClientId = getClientIdHeader(request as { headers: Record }); if (headerClientId) { return headerClientId; } const queryRecord = request.query && typeof request.query === 'object' ? (request.query as Record) : null; const queryClientId = queryRecord && typeof queryRecord.clientId === 'string' ? queryRecord.clientId : ''; return queryClientId.trim(); } async function ensureManagedShareAccessPin( request: { headers: Record; query?: unknown }, reply: FastifyReply, sharePath: string, ) { const pinStatus = await validateSharedResourceAccessPinBySharePath(sharePath, getRequestChatSharePin(request), { clientId: getRequestClientId(request), }); if (pinStatus.status === 'ok' || pinStatus.status === 'not-configured') { return pinStatus; } if (pinStatus.status === 'required') { reply.code(401).send({ code: 'share_pin_required', message: '이 공유 채팅방은 4자리 비밀번호 입력이 필요합니다.', }); return null; } if (pinStatus.status === 'invalid') { reply.code(401).send({ code: 'share_pin_invalid', message: '공유 채팅방 비밀번호가 올바르지 않습니다.', }); return null; } if (pinStatus.status === 'missing') { reply.code(404).send({ message: '공유 링크를 찾을 수 없습니다.', }); return null; } return null; } function hasManagedSharePermission( managedResource: | Awaited>['managedResource'] | null | undefined, permission: 'manage', ) { return managedResource?.token.permissions?.includes(permission) === true; } function hasManagedShareAllowedApp( managedResource: | Awaited>['managedResource'] | null | undefined, appId: string, ) { const normalizedAppId = appId.trim().toLowerCase(); const allowedAppIds = managedResource?.token.allowedAppIds; return (allowedAppIds ?? []).some((item) => item.trim().toLowerCase() === normalizedAppId); } 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); } return payload; }); app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => { const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); }); app.get(`${CHAT_LEGACY_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => { const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); }); app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => { const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); }); app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/resources/*`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); if (!tokenPayload) { return reply.code(404).send({ message: '공유 링크가 유효하지 않습니다.', }); } const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { return reply.code(403).send({ message: unavailableMessage, }); } const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath); if (!accessPinStatus) { return; } const requestedRelativePath = normalizeChatResourceWildcard(wildcard); const mappedSessionIds = managedContext.managedResource?.token.id ? await resolveChatShareTokenRoomSessionIds(managedContext.managedResource.token.id) : []; const allowedSessionIds = Array.from(new Set([tokenPayload.sessionId, ...mappedSessionIds].filter(Boolean))); const isAllowedSessionResource = allowedSessionIds.some((sessionId) => requestedRelativePath.startsWith(path.posix.join('.codex_chat', sessionId, 'resource') + '/'), ); const isAllowedManagedResource = requestedRelativePath.startsWith(RESOURCE_MANAGER_ROOT_PREFIX); if (!requestedRelativePath || (!isAllowedSessionResource && !isAllowedManagedResource)) { return reply.code(403).send({ message: '이 공유 링크에서 허용되지 않은 리소스입니다.', }); } return serveChatSharedResource(resolveChatAttachmentRepoPath(), requestedRelativePath, reply); }); app.get('/api/chat/setup', async () => { await ensureChatConversationTables(); return { ok: true, tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'], }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/rooms`, async (request, reply) => { const parsedPayload = z.object({ tokenSettingId: z.string().trim().min(1).max(120), chatTypeId: z.string().trim().min(1).max(120), chatTypeLabel: z.string().trim().min(1).max(200), name: z.string().trim().max(160).optional(), tokenName: z.string().trim().max(160).optional(), title: z.string().trim().max(200).optional(), requestBadgeLabel: z.string().trim().max(120).optional().nullable(), seedMessage: z.string().trim().min(1).max(20000), allowManageAccess: z.boolean().optional(), }).parse(request.body ?? {}); const payload = { ...parsedPayload, name: parsedPayload.name || parsedPayload.title || parsedPayload.tokenName || '', }; if (!payload.name) { return reply.code(400).send({ message: '공유 이름을 입력해 주세요.', }); } const tokenSetting = await getTokenSettingById(payload.tokenSettingId); if (!tokenSetting || !tokenSetting.enabled) { return reply.code(404).send({ message: '공유에 사용할 토큰 설정을 찾을 수 없습니다.', }); } if (!tokenSetting.allowedAppIds.some((item) => item.trim().toLowerCase() === 'chat-live')) { return reply.code(409).send({ message: '선택한 토큰 설정에는 공유 채팅방 권한이 없습니다.', }); } const clientId = getClientIdHeader(request); const sessionId = createManagedChatShareSessionId(); const requestId = createManagedChatShareRequestId(); const { userMessageId } = createManagedChatShareMessageIds(); const createdAt = new Date().toISOString(); await createChatConversation({ sessionId, clientId: clientId || null, title: payload.name, requestBadgeLabel: payload.requestBadgeLabel ?? null, chatTypeId: payload.chatTypeId, lastChatTypeId: payload.chatTypeId, contextLabel: payload.chatTypeLabel, contextDescription: null, notifyOffline: true, }); await appendChatConversationMessage( { sessionId, clientId: clientId || null, title: payload.name, requestBadgeLabel: payload.requestBadgeLabel ?? null, chatTypeId: payload.chatTypeId, lastChatTypeId: payload.chatTypeId, contextLabel: payload.chatTypeLabel, contextDescription: null, notifyOffline: true, }, { sessionId, messageId: userMessageId, author: 'user', text: payload.seedMessage, timestamp: createdAt, clientRequestId: requestId, parts: [], }, ); await upsertChatConversationRequest(sessionId, { requestId, requesterClientId: clientId || null, chatTypeId: payload.chatTypeId, chatTypeLabel: payload.chatTypeLabel, requestOrigin: 'composer', status: 'completed', statusMessage: '공유 채팅방 시작 요청을 준비했습니다.', userMessageId, userText: payload.seedMessage, }); const managedResourceTokenId = createManagedChatShareTokenId(); const token = randomUUID().replace(/-/g, '').slice(0, 24); const sharePath = resolveChatSharePath(token); const permissions = payload.allowManageAccess === true ? (['view', 'comment', 'download', 'upload', 'manage'] as const) : (['view', 'comment', 'download', 'upload'] as const); await upsertSharedResourceToken({ id: managedResourceTokenId, name: payload.name, description: [tokenSetting.description, `공유 유형: room`, `채팅유형: ${payload.chatTypeLabel}`].filter(Boolean).join('\n'), tokenSettingId: tokenSetting.id, tokenSettingSnapshot: { id: tokenSetting.id, name: tokenSetting.name, defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes, maxTokensPer30Days: tokenSetting.maxTokensPer30Days, maxTokensPer7Days: tokenSetting.maxTokensPer7Days, maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours, oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit, allowedAppIds: tokenSetting.allowedAppIds, }, resourceContext: { kind: 'request-bundle', sessionId, requestId, }, resourceAllowedAppIds: tokenSetting.allowedAppIds, resourceLabel: payload.name, resourcePath: sharePath, resourceType: 'chat-share', sharePath, permissions: [...permissions], enabled: true, expiresAt: tokenSetting.defaultExpiresInMinutes > 0 ? new Date(Date.now() + tokenSetting.defaultExpiresInMinutes * 60 * 1000).toISOString() : null, usageLimit: 0, }); await assignSharedResourceTokenToRequests(sessionId, [requestId], managedResourceTokenId); await upsertChatShareTokenRoomMap({ tokenId: managedResourceTokenId, sessionId, rootRequestId: requestId, isDefault: true, createdByClientId: clientId || null, }); return { ok: true, sessionId, requestId, token, sharePath, managedResourceTokenId, name: payload.name, requestBadgeLabel: payload.requestBadgeLabel ?? null, seedMessage: payload.seedMessage, permissions, hasAccessPin: false, tokenSetting: { id: tokenSetting.id, name: tokenSetting.name, defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes, maxTokensPer30Days: tokenSetting.maxTokensPer30Days, maxTokensPer7Days: tokenSetting.maxTokensPer7Days, maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours, oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit, allowedAppIds: tokenSetting.allowedAppIds, }, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/rooms`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ chatTypeId: z.string().trim().min(1).max(120), chatTypeLabel: z.string().trim().min(1).max(200), 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); if (!tokenPayload) { return reply.code(404).send({ message: '공유 링크가 유효하지 않습니다.', }); } const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { return reply.code(403).send({ message: unavailableMessage, }); } const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath); if (!accessPinStatus) { return; } if (!managedContext.managedResource || managedContext.managedResource.token.resourceType !== 'chat-share') { return reply.code(404).send({ message: '공유 채팅방 정보를 찾을 수 없습니다.', }); } if (!managedContext.managedResource.token.permissions.includes('manage')) { return reply.code(403).send({ message: '이 공유 링크에는 새 채팅방을 추가할 권한이 없습니다.', }); } const currentToken = managedContext.managedResource.token; const clientId = getRequestClientId(request); const sessionId = createManagedChatShareSessionId(); const requestId = createManagedChatShareRequestId(); const { userMessageId } = createManagedChatShareMessageIds(); const createdAt = new Date().toISOString(); await createChatConversation({ sessionId, clientId: clientId || null, title: payload.title, requestBadgeLabel: payload.requestBadgeLabel ?? null, chatTypeId: payload.chatTypeId, lastChatTypeId: payload.chatTypeId, contextLabel: payload.chatTypeLabel, contextDescription: null, notifyOffline: true, }); await appendChatConversationMessage( { sessionId, clientId: clientId || null, title: payload.title, requestBadgeLabel: payload.requestBadgeLabel ?? null, chatTypeId: payload.chatTypeId, lastChatTypeId: payload.chatTypeId, contextLabel: payload.chatTypeLabel, contextDescription: null, notifyOffline: true, }, { sessionId, messageId: userMessageId, author: 'user', text: payload.seedMessage, timestamp: createdAt, clientRequestId: requestId, parts: [], }, ); await upsertChatConversationRequest(sessionId, { requestId, requesterClientId: clientId || null, chatTypeId: payload.chatTypeId, chatTypeLabel: payload.chatTypeLabel, requestOrigin: 'composer', sharedResourceTokenId: currentToken.id, status: 'completed', statusMessage: '공유 채팅방 시작 요청을 준비했습니다.', userMessageId, userText: payload.seedMessage, }); await assignSharedResourceTokenToRequests(sessionId, [requestId], currentToken.id); await upsertChatShareTokenRoomMap({ tokenId: currentToken.id, sessionId, rootRequestId: requestId, isDefault: false, createdByClientId: clientId || null, linkContext: normalizeShareRoomLinkContext(payload), }); const room = await getChatShareTokenRoomMap(currentToken.id, sessionId); return { ok: true, room: room ? mapResolvedShareRoomItem(room) : { sessionId, requestId, isDefault: false, sortOrder: 0, title: payload.title, requestBadgeLabel: payload.requestBadgeLabel ?? null, chatTypeId: payload.chatTypeId, lastChatTypeId: payload.chatTypeId, contextLabel: payload.chatTypeLabel, contextDescription: null, notifyOffline: true, linkContext: normalizeShareRoomLinkContext(payload), createdAt, updatedAt: createdAt, }, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/room-settings`, 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(), accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(), accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(), chatTypeId: z.string().trim().min(1).max(120).optional().nullable(), chatTypeLabel: z.string().trim().min(1).max(200).optional().nullable(), title: z.string().trim().min(1).max(200).optional().nullable(), notifyOffline: z.boolean().optional().nullable(), }).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 unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { return reply.code(403).send({ message: unavailableMessage, }); } const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath); if (!accessPinStatus) { return; } if (!managedContext.managedResource || managedContext.managedResource.token.resourceType !== 'chat-share') { 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 currentToken = managedContext.managedResource.token; if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { return reply.code(403).send({ message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.', }); } const saved = await upsertSharedResourceToken({ id: currentToken.id, name: currentToken.name, description: currentToken.description, tokenSettingId: currentToken.tokenSettingId, tokenSettingSnapshot: currentToken.tokenSettingSnapshot, resourceLabel: currentToken.resourceLabel, resourcePath: currentToken.resourcePath, resourceType: currentToken.resourceType, shareToken: currentToken.shareToken, sharePath: currentToken.sharePath, resourceAllowedAppIds: currentToken.allowedAppIds, resourceAllowedAppIdsOverrideEnabled: currentToken.resourceAllowedAppIdsOverrideEnabled, permissions: currentToken.permissions, enabled: currentToken.enabled, expiresAt: currentToken.expiresAt, usageLimit: currentToken.usageLimit, accessPin: payload.accessPin, accessPinPromptTtlMinutes: payload.accessPinPromptTtlMinutes, allowAccessPinChangeWithoutManage: false, }); if (!saved) { return reply.code(404).send({ message: '공유 채팅방 설정 저장에 실패했습니다.', }); } let updatedConversation = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, getRequestClientId(request)); if (payload.chatTypeId || payload.title || payload.notifyOffline != null) { updatedConversation = await updateChatConversationContext(resolvedRoomContext.activeRoom.sessionId, { clientId: getRequestClientId(request), chatTypeId: payload.chatTypeId?.trim() || undefined, lastChatTypeId: payload.chatTypeId?.trim() || undefined, contextLabel: payload.chatTypeLabel?.trim() || undefined, contextDescription: payload.chatTypeId ? null : undefined, title: payload.title?.trim() || undefined, notifyOffline: payload.notifyOffline ?? undefined, }); } return { ok: true, hasAccessPin: saved.token.hasAccessPin, accessPinPromptTtlMinutes: saved.token.accessPinPromptTtlMinutes, conversation: updatedConversation ? { sessionId: updatedConversation.sessionId, title: updatedConversation.title, requestBadgeLabel: updatedConversation.requestBadgeLabel ?? null, chatTypeId: updatedConversation.chatTypeId ?? null, lastChatTypeId: updatedConversation.lastChatTypeId ?? null, contextLabel: updatedConversation.contextLabel ?? null, contextDescription: updatedConversation.contextDescription ?? null, notifyOffline: updatedConversation.notifyOffline, } : null, }; }); app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/runtime`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const query = z.object({ sessionId: z.string().trim().min(1).max(120).optional().nullable(), }).parse(request.query ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); if (!tokenPayload) { return reply.code(404).send({ message: '공유 링크가 유효하지 않습니다.', }); } const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { return reply.code(403).send({ message: unavailableMessage, }); } if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { return; } if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { return reply.code(403).send({ message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.', }); } const resolvedRoomContext = await resolveActiveManagedShareRoom({ managedResource: managedContext.managedResource, tokenPayload, requestedSessionId: query.sessionId ?? null, }); if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { return reply.code(403).send({ message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', }); } return { ok: true, item: buildManagedShareRoomRuntimeSnapshot(chatRuntimeService.getSnapshot(), resolvedRoomContext.activeRoom.sessionId), }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/runtime-requests/:requestId/cancel`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z.object({ sessionId: z.string().trim().min(1).max(120).optional().nullable(), }).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 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?.includes('manage')) { return reply.code(403).send({ message: '이 공유 링크에는 처리중 세션 관리 권한이 없습니다.', }); } if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { return reply.code(403).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 controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } const detail = controller.getJobDetail(params.requestId); if (!detail.item || detail.item.sessionId !== resolvedRoomContext.activeRoom.sessionId) { return reply.code(404).send({ message: '이 채팅방에서 취소할 처리중 세션을 찾지 못했습니다.', }); } if (detail.availableActions.remove) { const removed = await controller.removeQueuedJob(params.requestId); if (!removed) { return reply.code(409).send({ message: '대기 요청 취소 처리에 실패했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.', }); } return { ok: true, action: 'removed', }; } if (detail.availableActions.cancel) { const cancelled = await controller.cancelJob(params.requestId); if (!cancelled) { return reply.code(409).send({ message: '실행 중 요청 취소 처리에 실패했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.', }); } return { ok: true, action: 'cancelled', }; } return reply.code(409).send({ message: '이미 종료된 요청이라 지금은 취소할 수 없습니다.', }); }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}`, async (request, reply) => { const parsedPayload = z.object({ kind: z.enum(['request-bundle', 'inquiry-message', 'prompt']), sessionId: z.string().trim().min(1).max(120), requestId: z.string().trim().min(1).max(120), tokenSettingId: z.string().trim().min(1).max(120), name: z.string().trim().max(160).optional(), tokenName: z.string().trim().max(160).optional(), accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(), sourceMessageId: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), promptIndex: z.coerce.number().int().min(0).max(99).optional(), promptSignature: z.string().trim().min(1).max(8000).optional(), }).parse(request.body ?? {}); const payload = { ...parsedPayload, name: parsedPayload.name || parsedPayload.tokenName || '', }; if (!payload.name) { return reply.code(400).send({ message: '공유 이름을 입력해 주세요.', }); } const viewerClientId = getClientIdHeader(request); const clientId = canViewAllConversations(request) ? null : viewerClientId; const conversation = await getChatConversation(payload.sessionId, clientId || null); if (!conversation) { return reply.code(404).send({ message: '공유할 채팅방을 찾을 수 없습니다.', }); } const requestItem = await getChatConversationRequest(payload.sessionId, payload.requestId); if (!requestItem) { return reply.code(404).send({ message: '공유할 요청을 찾을 수 없습니다.', }); } const tokenSetting = await getTokenSettingById(payload.tokenSettingId); if (!tokenSetting || !tokenSetting.enabled) { return reply.code(404).send({ message: '공유에 사용할 토큰 설정을 찾을 수 없습니다.', }); } if (!tokenSetting.allowedAppIds.some((item) => item.trim().toLowerCase() === 'chat-live')) { return reply.code(409).send({ message: '선택한 토큰 설정에는 공유 채팅방 권한이 없습니다.', }); } const shareSnapshot = await buildChatShareSnapshot({ version: CHAT_SHARE_TOKEN_VERSION, kind: payload.kind, sessionId: payload.sessionId, requestId: payload.requestId, tokenSettingId: tokenSetting.id, tokenSettingName: tokenSetting.name, tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes, tokenSettingAllowedAppIds: tokenSetting.allowedAppIds, tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days, tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days, tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours, tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit, sourceMessageId: payload.sourceMessageId, promptIndex: payload.promptIndex, promptSignature: payload.promptSignature, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유할 채팅 범위를 찾을 수 없습니다.', }); } if (payload.kind === 'prompt') { if (payload.promptIndex == null || !payload.promptSignature) { return reply.code(400).send({ message: 'prompt 공유에는 prompt 대상 정보가 필요합니다.', }); } if (!shareSnapshot.promptTarget) { return reply.code(404).send({ message: '공유할 prompt 대상을 찾을 수 없습니다.', }); } } const managedResourceTokenId = createManagedChatShareTokenId(); const token = randomUUID().replace(/-/g, '').slice(0, 24); const sharePath = resolveChatSharePath(token); const managedShareConversation = await materializeManagedShareConversation({ shareSnapshot, managedResourceTokenId, ownerClientId: clientId || null, shareTitle: payload.name, }); await upsertSharedResourceToken({ id: managedResourceTokenId, name: payload.name, description: [tokenSetting.description, `공유 유형: ${payload.kind}`].filter(Boolean).join('\n'), tokenSettingId: tokenSetting.id, tokenSettingSnapshot: { id: tokenSetting.id, name: tokenSetting.name, defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes, maxTokensPer30Days: tokenSetting.maxTokensPer30Days, maxTokensPer7Days: tokenSetting.maxTokensPer7Days, maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours, oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit, allowedAppIds: tokenSetting.allowedAppIds, }, resourceContext: { kind: payload.kind, sessionId: managedShareConversation.sessionId, requestId: managedShareConversation.requestId, sourceMessageId: payload.sourceMessageId ?? null, promptIndex: payload.promptIndex ?? null, promptSignature: payload.promptSignature ?? null, }, resourceAllowedAppIds: tokenSetting.allowedAppIds, resourceLabel: payload.name, resourcePath: sharePath, resourceType: 'chat-share', sharePath, permissions: payload.kind === 'prompt' ? ['view', 'comment', 'upload'] : ['view', 'comment', 'download', 'upload'], enabled: true, accessPin: payload.accessPin ?? null, expiresAt: tokenSetting.defaultExpiresInMinutes > 0 ? new Date(Date.now() + tokenSetting.defaultExpiresInMinutes * 60 * 1000).toISOString() : null, usageLimit: 0, }); return { ok: true, token, sharePath, name: payload.name, tokenSetting: { id: tokenSetting.id, name: tokenSetting.name, defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes, maxTokensPer30Days: tokenSetting.maxTokensPer30Days, maxTokensPer7Days: tokenSetting.maxTokensPer7Days, maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours, oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit, allowedAppIds: tokenSetting.allowedAppIds, }, managedResourceTokenId, hasAccessPin: Boolean(payload.accessPin), }; }); app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).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); if (!tokenPayload) { return reply.code(404).send({ message: '공유 링크가 유효하지 않습니다.', }); } const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { return reply.code(403).send({ message: unavailableMessage, }); } const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath); if (!accessPinStatus) { return; } const resolvedRoomContext = await resolveActiveManagedShareRoom({ managedResource: managedContext.managedResource, tokenPayload, requestedSessionId: query.sessionId ?? null, }); const activeRoom = resolvedRoomContext.activeRoom; if (!activeRoom) { return reply.code(404).send({ message: '공유 채팅방 목록을 찾을 수 없습니다.', }); } const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { sessionId: activeRoom.sessionId, requestId: activeRoom.requestId, detailLevel: query.view === 'initial' ? 'initial' : 'full', }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } if (managedContext.managedResource) { void recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 채팅 링크를 열었습니다.', detail: managedContext.managedResource.token.resourceLabel, }).catch((error) => { request.log.warn( { err: error, managedResourceTokenId: managedContext.managedResource?.token.id, }, 'Failed to record shared chat view usage', ); }); } const effectiveTokenSetting = managedContext.managedResource?.token.tokenSettingSnapshot ?? resolveChatShareTokenSettingSnapshot(tokenPayload); const effectiveExpiresAt = managedContext.managedResource ? resolveSharedResourceTokenEffectiveExpiresAt(managedContext.managedResource.token, effectiveTokenSetting) : null; const blockedReason = resolveShareBlockedReason(shareSnapshot.requests, effectiveTokenSetting); return { ok: true, share: { kind: tokenPayload.kind, sessionId: activeRoom.sessionId, requestId: activeRoom.requestId, sharePath: resolveChatSharePath(params.token), createdAt: managedContext.managedResource?.token.createdAt ?? null, expiresAt: effectiveExpiresAt, tokenSetting: { id: effectiveTokenSetting.id, name: effectiveTokenSetting.name, defaultExpiresInMinutes: effectiveTokenSetting.defaultExpiresInMinutes, maxTokensPer30Days: effectiveTokenSetting.maxTokensPer30Days, maxTokensPer7Days: effectiveTokenSetting.maxTokensPer7Days, maxTokensPer5Hours: effectiveTokenSetting.maxTokensPer5Hours, oneTimeTokenLimit: effectiveTokenSetting.oneTimeTokenLimit, allowedAppIds: managedContext.managedResource?.token.allowedAppIds ?? effectiveTokenSetting.allowedAppIds, }, managedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, permissions: managedContext.managedResource?.token.permissions ?? [], hasAccessPin: managedContext.managedResource?.token.hasAccessPin ?? false, accessPinPromptTtlMinutes: managedContext.managedResource?.token.accessPinPromptTtlMinutes ?? null, accessPinSessionExpiresAt: accessPinStatus.status === 'ok' ? accessPinStatus.sessionExpiresAt ?? null : null, canSendMessage: !blockedReason, blockedReason, }, conversation: { sessionId: shareSnapshot.conversation?.sessionId ?? activeRoom.sessionId, title: shareSnapshot.conversation?.title ?? activeRoom.title ?? '공유 채팅', requestBadgeLabel: shareSnapshot.conversation?.requestBadgeLabel ?? null, chatTypeId: shareSnapshot.conversation?.chatTypeId ?? null, lastChatTypeId: shareSnapshot.conversation?.lastChatTypeId ?? null, contextLabel: shareSnapshot.conversation?.contextLabel ?? null, contextDescription: shareSnapshot.conversation?.contextDescription ?? null, notifyOffline: shareSnapshot.conversation?.notifyOffline === true, }, rootRequestId: shareSnapshot.rootRequestId, targetRequest: shareSnapshot.targetRequest, requests: shareSnapshot.requests, messages: shareSnapshot.messages, activityLogs: shareSnapshot.activityLogs, roomRequestCounts: shareSnapshot.roomRequestCounts, oldestLoadedMessageId: shareSnapshot.oldestLoadedMessageId, hasOlderMessages: shareSnapshot.hasOlderMessages, rooms: resolvedRoomContext.rooms, activeSessionId: activeRoom.sessionId, promptTarget: shareSnapshot.promptTarget, detailLevel: shareSnapshot.detailLevel, refreshedAt: new Date().toISOString(), }; }); app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/conversations/:sessionId`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const query = z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), beforeMessageId: z.coerce.number().int().positive().optional(), }).parse(request.query ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); if (!tokenPayload) { return reply.code(404).send({ message: '공유 링크가 유효하지 않습니다.', }); } const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { return reply.code(403).send({ message: unavailableMessage, }); } if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { return; } const resolvedRoomContext = await resolveActiveManagedShareRoom({ managedResource: managedContext.managedResource, tokenPayload, requestedSessionId: params.sessionId, }); if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { return reply.code(403).send({ message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', }); } const item = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, null); if (!item) { return reply.code(404).send({ message: '채팅방을 찾을 수 없습니다.', }); } const detailPage = await listChatConversationDetailPage(resolvedRoomContext.activeRoom.sessionId, { limit: query.limit ?? 40, beforeMessageId: query.beforeMessageId ?? null, }); return { ok: true, item, messages: detailPage.messages, requests: detailPage.requests, activityLogs: detailPage.activityLogs, oldestLoadedMessageId: detailPage.oldestLoadedMessageId, hasOlderMessages: detailPage.hasOlderMessages, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/clear`, 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(), }).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: '공유 링크가 유효하지 않습니다.', }); } if (tokenPayload.kind === 'prompt') { return reply.code(400).send({ message: 'prompt 공유 링크에서는 채팅방 기록을 비울 수 없습니다.', }); } 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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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') && !managedContext.managedResource.token.permissions.includes('upload')) { return reply.code(403).send({ message: '이 공유 링크에는 채팅 기록을 비울 권한이 없습니다.', }); } getActiveChatService()?.resetSessionData(resolvedRoomContext.activeRoom.sessionId); chatRuntimeService.clearSession(resolvedRoomContext.activeRoom.sessionId); const item = await clearChatConversationData(resolvedRoomContext.activeRoom.sessionId, null); if (!item) { return reply.code(404).send({ message: '초기화할 채팅방을 찾을 수 없습니다.', }); } if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 채팅에서 채팅방 기록을 초기화했습니다.', detail: resolvedRoomContext.activeRoom.sessionId, }); } return { ok: true, item, }; }); app.delete(`${CHAT_SHARE_ROUTE_PREFIX}/:token/rooms/:sessionId`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); if (!tokenPayload) { return reply.code(404).send({ message: '공유 링크가 유효하지 않습니다.', }); } if (tokenPayload.kind === 'prompt') { return reply.code(400).send({ message: 'prompt 공유 링크에서는 채팅방을 삭제할 수 없습니다.', }); } 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 || managedContext.managedResource.token.resourceType !== 'chat-share') { return reply.code(404).send({ message: '공유 채팅방 정보를 찾을 수 없습니다.', }); } if (!managedContext.managedResource.token.permissions.includes('manage')) { return reply.code(403).send({ message: '이 공유 링크에는 채팅방을 삭제할 권한이 없습니다.', }); } if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { return reply.code(403).send({ message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.', }); } const resolvedRoomContext = await resolveActiveManagedShareRoom({ managedResource: managedContext.managedResource, tokenPayload, requestedSessionId: params.sessionId, }); if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { return reply.code(403).send({ message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', }); } if (resolvedRoomContext.rooms.length <= 1) { return reply.code(400).send({ message: '마지막 채팅방은 삭제할 수 없습니다.', }); } getActiveChatService()?.forgetSession(params.sessionId); chatRuntimeService.clearSession(params.sessionId); const deleted = await deleteChatConversation(params.sessionId); const archived = await archiveChatShareTokenRoomMap(managedContext.managedResource.token.id, params.sessionId); if (!deleted || !archived.archived) { return reply.code(404).send({ message: '삭제할 채팅방을 찾을 수 없습니다.', }); } if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-manager', summary: '공유 채팅방을 삭제했습니다.', detail: params.sessionId, }); } return { ok: true, deleted: true, deletedSessionId: params.sessionId, nextRoomSessionId: archived.nextDefaultRoom?.sessionId ?? null, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/messages`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ text: z.string().trim().min(1).max(20000), mode: z.enum(['queue', 'direct']).optional(), parentRequestId: z.string().trim().min(1).max(120).optional().nullable(), sessionId: z.string().trim().min(1).max(120).optional().nullable(), codexModel: z.string().trim().max(120).optional().nullable(), }).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: '공유 링크가 유효하지 않습니다.', }); } if (tokenPayload.kind === 'prompt') { return reply.code(400).send({ message: 'prompt 공유 링크에서는 일반 메시지를 전송할 수 없습니다.', }); } 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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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') && !managedContext.managedResource.token.permissions.includes('upload')) { return reply.code(403).send({ message: '이 공유 링크에는 답변 전송 권한이 없습니다.', }); } const blockedReason = resolveShareBlockedReason( shareSnapshot.requests, resolveChatShareTokenSettingSnapshot(tokenPayload), ); if (blockedReason) { return reply.code(409).send({ message: blockedReason, }); } const requestedParentRequestId = payload.parentRequestId?.trim() || ''; const resolvedParentRequestId = requestedParentRequestId ? resolveRecoveredShareParentRequestId( shareSnapshot, requestedParentRequestId, [shareSnapshot.targetRequest.requestId], ) : null; if ( resolvedParentRequestId && !shareSnapshot.requests.some((request) => request.requestId.trim() === resolvedParentRequestId) ) { return reply.code(403).send({ message: '이 공유 링크 범위를 벗어난 답변 참조입니다.', }); } const queuedRequestId = await getActiveChatService()?.submitExternalMessage( resolvedRoomContext.activeRoom.sessionId, payload.text, { mode: payload.mode === 'direct' ? 'direct' : 'queue', requestOrigin: 'composer', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, parentRequestId: resolvedParentRequestId, contextOverride: Object.prototype.hasOwnProperty.call(payload, 'codexModel') ? { codexModel: payload.codexModel ?? null } : undefined, clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, }, ); if (!queuedRequestId) { return reply.code(503).send({ message: '공유 요청을 즉시 전송하지 못했습니다.', }); } if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 채팅에서 후속 메시지를 전송했습니다.', detail: resolvedParentRequestId, }); } return { ok: true, queuedRequestId, }; }); 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(), 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); 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 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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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') && !managedContext.managedResource.token.permissions.includes('upload')) { return reply.code(403).send({ message: '이 공유 링크에는 첨부 업로드 권한이 없습니다.', }); } const blockedReason = resolveShareBlockedReason( shareSnapshot.requests, resolveChatShareTokenSettingSnapshot(tokenPayload), ); if (blockedReason) { return reply.code(409).send({ message: blockedReason, }); } 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({ message: saved.message, }); } if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 채팅에 첨부 파일을 업로드했습니다.', detail: shareSnapshot.targetRequest.requestId, }); } return { ok: true, item: saved.item, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/prompt-submit`, 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(), parentRequestId: z.string().trim().min(1).max(120), promptIndex: z.number().int().min(0).max(99), promptTitle: z.string().trim().min(1).max(500), promptSignature: z.string().trim().min(1).max(8000), sourceMessageId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), selectedValues: z.array(z.string().trim().min(1).max(500)).max(50), freeText: z.string().max(10000).optional().nullable(), stepSelections: z.array( z.object({ stepKey: z.string().trim().min(1).max(120), stepTitle: z.string().trim().max(500).optional().nullable(), selectedValues: z.array(z.string().trim().min(1).max(500)).max(50), freeText: z.string().max(10000), skipped: z.boolean().optional(), }), ).max(20).optional(), summaryText: z.string().max(10000).optional().nullable(), attachments: z.array(chatComposerAttachmentSchema).max(20).optional(), followupText: z.string().trim().min(1).max(20000), mode: z.enum(['queue', 'direct']).optional(), contextRef: chatPromptContextRefSchema, }).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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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: '이 공유 링크에는 prompt 응답 권한이 없습니다.', }); } const blockedReason = resolveShareBlockedReason( shareSnapshot.requests, resolveChatShareTokenSettingSnapshot(tokenPayload), ); if (blockedReason) { return reply.code(409).send({ message: blockedReason, }); } const sourceMessageParentRequestId = shareSnapshot.messages .find((message: { id: number; clientRequestId?: string | null }) => message.id === payload.sourceMessageId) ?.clientRequestId?.trim() || ''; const normalizedParentRequestId = resolveRecoveredShareParentRequestId( shareSnapshot, payload.parentRequestId, [sourceMessageParentRequestId, shareSnapshot.targetRequest.requestId], ); if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) { return reply.code(403).send({ message: '이 공유 링크 범위를 벗어난 prompt 입니다.', }); } if ( tokenPayload.kind === 'prompt' && (normalizedParentRequestId !== tokenPayload.requestId || payload.promptIndex !== tokenPayload.promptIndex || payload.sourceMessageId !== tokenPayload.sourceMessageId || payload.promptSignature !== tokenPayload.promptSignature) ) { return reply.code(403).send({ message: '이 공유 링크에서 허용되지 않은 prompt 입니다.', }); } const existingPromptRequest = await findExistingActivePromptFollowupRequest( resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, payload.followupText, ); const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage( resolvedRoomContext.activeRoom.sessionId, payload.followupText, { mode: resolvePromptFollowupMode(payload.mode), requestOrigin: 'prompt', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, parentRequestId: normalizedParentRequestId, promptContextRef: payload.contextRef ?? null, clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, }, ); if (!queuedRequestId) { return reply.code(503).send({ message: 'prompt 선택 전송을 시작하지 못했습니다.', }); } const persisted = await persistChatConversationPromptSelection( resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, payload, ); if (!persisted) { return reply.code(404).send({ message: '즉시 전송은 접수됐지만 prompt 선택 상태를 저장하지 못했습니다.', }); } getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, persisted.request); getActiveChatService()?.broadcastMessageUpdate(resolvedRoomContext.activeRoom.sessionId, persisted.message); if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 prompt 응답을 제출했습니다.', detail: normalizedParentRequestId, }); } return { ok: true, item: persisted.request, message: persisted.message, queuedRequestId, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/manual-completion`, 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(), parentRequestId: z.string().trim().min(1).max(120), type: z.enum(['prompt', 'verification']), }).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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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 normalizedParentRequestId = resolveRecoveredShareParentRequestId( shareSnapshot, payload.parentRequestId, [shareSnapshot.targetRequest.requestId], ); if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) { return reply.code(403).send({ message: '이 공유 링크 범위를 벗어난 요청입니다.', }); } if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) { return reply.code(403).send({ message: '이 공유 링크에서 허용되지 않은 요청입니다.', }); } let item = null; try { item = await markChatConversationRequestManualCompletion( resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, payload.type, ); } catch (error) { if (error instanceof ChatConversationManualCompletionBlockedError) { return reply.code(409).send({ message: error.message, }); } throw error; } if (!item) { return reply.code(404).send({ message: '완료 처리할 요청을 찾지 못했습니다.', }); } getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, item); return { ok: true, item, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-cancel`, 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(), parentRequestId: z.string().trim().min(1).max(120), }).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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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 normalizedParentRequestId = resolveRecoveredShareParentRequestId( shareSnapshot, payload.parentRequestId, [shareSnapshot.targetRequest.requestId], ); if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) { return reply.code(403).send({ message: '이 공유 링크 범위를 벗어난 요청입니다.', }); } if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) { return reply.code(403).send({ message: '이 공유 링크에서 허용되지 않은 요청입니다.', }); } const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null; if ( !targetRequest || targetRequest.hasResponse || targetRequest.status !== 'failed' || (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청' ) { return reply.code(409).send({ message: '지금은 이 요청을 취소 처리할 수 없습니다.', }); } const result = await cancelUnansweredChatConversationRequest( resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, '사용자 요청으로 중단된 요청을 취소 처리했습니다.', ); if (!result.cancelled || !result.item) { if (result.reason === 'answered') { return reply.code(409).send({ message: '이미 답변이 연결된 요청은 취소 처리할 수 없습니다.', }); } if (result.reason === 'active') { return reply.code(409).send({ message: '현재 처리 중인 요청은 여기서 취소 처리할 수 없습니다.', }); } if (result.reason === 'already_terminal') { return reply.code(409).send({ message: '이미 취소 처리된 요청입니다.', }); } return reply.code(404).send({ message: '취소 처리할 요청을 찾지 못했습니다.', }); } getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, result.item); return { ok: true, item: result.item, }; }); app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-retry`, 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(), parentRequestId: z.string().trim().min(1).max(120), }).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, }); if (!shareSnapshot) { return reply.code(404).send({ message: '공유 대상을 찾을 수 없습니다.', }); } 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 blockedReason = resolveShareBlockedReason( shareSnapshot.requests, resolveChatShareTokenSettingSnapshot(tokenPayload), ); if (blockedReason) { return reply.code(409).send({ message: blockedReason, }); } const normalizedParentRequestId = resolveRecoveredShareParentRequestId( shareSnapshot, payload.parentRequestId, [shareSnapshot.targetRequest.requestId], ); if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) { return reply.code(403).send({ message: '이 공유 링크 범위를 벗어난 요청입니다.', }); } if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) { return reply.code(403).send({ message: '이 공유 링크에서 허용되지 않은 요청입니다.', }); } const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null; if ( !targetRequest || targetRequest.hasResponse || targetRequest.status !== 'failed' || (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청' ) { return reply.code(409).send({ message: '지금은 이 요청을 재처리할 수 없습니다.', }); } const normalizedUserText = targetRequest.userText.trim(); if (!normalizedUserText) { return reply.code(409).send({ message: '재처리할 요청 본문이 없습니다.', }); } const queuedRequestId = await getActiveChatService()?.submitExternalMessage( resolvedRoomContext.activeRoom.sessionId, normalizedUserText, { requestId: normalizedParentRequestId, mode: 'direct', requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null, clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, }, ); if (!queuedRequestId) { return reply.code(503).send({ message: '중단된 요청 재처리를 시작하지 못했습니다.', }); } if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 채팅에서 중단 요청 재처리를 시작했습니다.', detail: normalizedParentRequestId, }); } return { ok: true, queuedRequestId, }; }); app.get('/api/chat/conversations', async (request) => { const query = z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), }).parse(request.query ?? {}); const viewerClientId = getClientIdHeader(request); const clientId = canViewAllConversations(request) ? null : viewerClientId; const items = await listChatConversations(clientId, query.limit ?? 200, viewerClientId || null); return { ok: true, items, }; }); app.get('/api/chat/source-changes', async (request) => { const query = z.object({ limit: z.coerce.number().int().min(1).max(500).optional(), }).parse(request.query ?? {}); const viewerClientId = getClientIdHeader(request); const clientId = canViewAllConversations(request) ? null : viewerClientId; const items = await listChatSourceChangeSnapshots(clientId, query.limit ?? 300); return { ok: true, items, }; }); app.get('/api/chat/runtime', async () => { return { ok: true, item: chatRuntimeService.getSnapshot(), }; }); app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => { 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({ message: saved.message, }); } return { ok: true, item: saved.item, }; }); app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } return { ok: true, item: controller.getJobDetail(params.requestId), }; }); app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } const cancelled = await controller.cancelJob(params.requestId); if (!cancelled) { return reply.code(404).send({ message: '취소할 실행 중 요청을 찾지 못했습니다.', }); } return { ok: true, cancelled: true, }; }); app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } const removed = await controller.removeQueuedJob(params.requestId); if (!removed) { return reply.code(404).send({ message: '제거할 대기 요청을 찾지 못했습니다.', }); } return { ok: true, removed: true, }; }); app.post('/api/chat/runtime/jobs/:requestId/rollback', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z .object({ sessionId: z.string().trim().min(1).max(120).optional(), }) .parse(request.body ?? {}); chatRuntimeService.appendLog(params.requestId, '사용자 요청으로 최근 실행 롤백을 시작합니다.'); try { const result = await rollbackChatRuntimeRequest({ requestId: params.requestId, sessionId: payload.sessionId, }); await upsertChatConversationRequest(result.sessionId, { requestId: result.requestId, status: 'cancelled', statusMessage: '사용자 요청으로 최근 실행 변경을 롤백했습니다.', }); chatRuntimeService.setArchivedJobTerminalStatus( params.requestId, 'cancelled', '최근 실행이 롤백되어 상태를 취소로 변경했습니다.', ); chatRuntimeService.appendLog(params.requestId, '최근 실행 롤백이 완료되었습니다.'); return { ok: true, ...result, }; } catch (error) { const message = error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.'; chatRuntimeService.appendLog(params.requestId, `최근 실행 롤백 실패: ${message}`); return reply.code(409).send({ message, }); } }); app.post('/api/chat/conversations', async (request) => { const payload = z.object({ sessionId: z.string().trim().min(1).max(120), title: z.string().trim().max(200).optional(), draftText: z.string().max(200000).optional().nullable(), requestBadgeLabel: z.string().trim().max(120).optional().nullable(), codexModel: z.string().trim().max(120).optional().nullable(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), generalSectionName: z.string().trim().max(120).optional().nullable(), contextLabel: z.string().trim().max(200).optional(), contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional(), notifyOffline: z.boolean().optional(), }).parse(request.body ?? {}); const clientId = getClientIdHeader(request); const item = await createChatConversation({ sessionId: payload.sessionId, clientId: clientId || null, title: payload.title ?? '새 대화', draftText: payload.draftText ?? '', requestBadgeLabel: payload.requestBadgeLabel ?? null, codexModel: payload.codexModel ?? null, chatTypeId: payload.chatTypeId ?? null, lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null, generalSectionName: payload.generalSectionName ?? null, contextLabel: payload.contextLabel ?? null, contextDescription: payload.contextDescription ?? null, notifyOffline: payload.notifyOffline ?? true, }); return { ok: true, item, }; }); app.get('/api/chat/conversations/:sessionId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const query = z.object({ limit: z.coerce.number().int().min(1).max(500).optional(), beforeMessageId: z.coerce.number().int().positive().optional(), }).parse(request.query ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const item = await getChatConversation(params.sessionId, clientId || null); if (!item) { return reply.code(404).send({ message: '채팅방을 찾을 수 없습니다.', }); } const messageLimit = query.limit ?? 10; const detailPage = await listChatConversationDetailPage(params.sessionId, { limit: messageLimit, beforeMessageId: query.beforeMessageId ?? null, }); return { ok: true, item, messages: detailPage.messages, requests: detailPage.requests, activityLogs: detailPage.activityLogs, oldestLoadedMessageId: detailPage.oldestLoadedMessageId, hasOlderMessages: detailPage.hasOlderMessages, }; }); app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const clientId = getClientIdHeader(request); if (!clientId) { return reply.code(400).send({ message: '읽음 처리를 위한 clientId가 필요합니다.', }); } const result = await markChatConversationResponsesRead(params.sessionId, clientId); if (!result) { return reply.code(404).send({ message: '읽음 처리할 채팅방을 찾지 못했습니다.', }); } return { ok: true, ...result, }; }); app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId); if (!result.deleted) { if (result.reason === 'not_found') { return reply.code(404).send({ message: '삭제할 요청을 찾지 못했습니다.', }); } if (result.reason === 'answered') { return reply.code(409).send({ message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.', }); } return reply.code(409).send({ message: '현재 처리 중인 요청은 삭제할 수 없습니다.', }); } return { ok: true, deleted: true, sessionId: params.sessionId, requestId: params.requestId, }; }); app.post('/api/chat/conversations/:sessionId/requests/:requestId/manual-completion', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z.object({ type: z.enum(['prompt', 'verification']), }).parse(request.body ?? {}); let item = null; try { item = await markChatConversationRequestManualCompletion( params.sessionId, params.requestId, payload.type, ); } catch (error) { if (error instanceof ChatConversationManualCompletionBlockedError) { return reply.code(409).send({ message: error.message, }); } throw error; } if (!item) { return reply.code(404).send({ message: '완료 처리할 요청을 찾지 못했습니다.', }); } getActiveChatService()?.broadcastRequestUpdate(params.sessionId, item); return { ok: true, item, }; }); app.post('/api/chat/conversations/:sessionId/requests/:requestId/prompt-selection', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z.object({ parentRequestId: z.string().trim().min(1).max(120), promptIndex: z.number().int().min(0).max(99), promptTitle: z.string().trim().min(1).max(500), promptSignature: z.string().trim().min(1).max(8000), sourceMessageId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), selectedValues: z.array(z.string().trim().min(1).max(500)).max(50), freeText: z.string().max(10000).optional().nullable(), stepSelections: z.array( z.object({ stepKey: z.string().trim().min(1).max(120), stepTitle: z.string().trim().max(500).optional().nullable(), selectedValues: z.array(z.string().trim().min(1).max(500)).max(50), freeText: z.string().max(10000), skipped: z.boolean().optional(), }), ).max(20).optional(), summaryText: z.string().max(10000).optional().nullable(), attachments: z.array(chatComposerAttachmentSchema).max(20).optional(), }).parse(request.body ?? {}); if (params.requestId !== payload.parentRequestId) { return reply.code(400).send({ message: 'prompt 선택 저장 대상 요청이 올바르지 않습니다.', }); } const persisted = await persistChatConversationPromptSelection( params.sessionId, params.requestId, payload, ); if (!persisted) { return reply.code(404).send({ message: 'prompt 선택 상태를 저장할 응답 메시지를 찾지 못했습니다.', }); } getActiveChatService()?.broadcastRequestUpdate(params.sessionId, persisted.request); getActiveChatService()?.broadcastMessageUpdate(params.sessionId, persisted.message); return { ok: true, item: persisted.request, message: persisted.message, }; }); app.post('/api/chat/conversations/:sessionId/requests/:requestId/prompt-selection/submit', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z.object({ parentRequestId: z.string().trim().min(1).max(120), promptIndex: z.number().int().min(0).max(99), promptTitle: z.string().trim().min(1).max(500), promptSignature: z.string().trim().min(1).max(8000), sourceMessageId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), selectedValues: z.array(z.string().trim().min(1).max(500)).max(50), freeText: z.string().max(10000).optional().nullable(), stepSelections: z.array( z.object({ stepKey: z.string().trim().min(1).max(120), stepTitle: z.string().trim().max(500).optional().nullable(), selectedValues: z.array(z.string().trim().min(1).max(500)).max(50), freeText: z.string().max(10000), skipped: z.boolean().optional(), }), ).max(20).optional(), summaryText: z.string().max(10000).optional().nullable(), attachments: z.array(chatComposerAttachmentSchema).max(20).optional(), followupText: z.string().trim().min(1).max(20000), mode: z.enum(['queue', 'direct']).optional(), contextRef: chatPromptContextRefSchema, }).parse(request.body ?? {}); if (params.requestId !== payload.parentRequestId) { return reply.code(400).send({ message: 'prompt 선택 저장 대상 요청이 올바르지 않습니다.', }); } const existingPromptRequest = await findExistingActivePromptFollowupRequest( params.sessionId, params.requestId, payload.followupText, ); const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(params.sessionId, payload.followupText, { mode: resolvePromptFollowupMode(payload.mode), requestOrigin: 'prompt', parentRequestId: params.requestId, promptContextRef: payload.contextRef ?? null, }); if (!queuedRequestId) { return reply.code(503).send({ message: 'prompt 선택 후속 Codex 실행을 시작하지 못했습니다.', }); } const persisted = await persistChatConversationPromptSelection( params.sessionId, params.requestId, payload, ); if (!persisted) { return reply.code(404).send({ message: '후속 Codex 실행은 접수됐지만 prompt 선택 상태를 저장할 응답 메시지를 찾지 못했습니다.', }); } getActiveChatService()?.broadcastRequestUpdate(params.sessionId, persisted.request); getActiveChatService()?.broadcastMessageUpdate(params.sessionId, persisted.message); return { ok: true, item: persisted.request, message: persisted.message, queuedRequestId, }; }); app.patch('/api/chat/conversations/:sessionId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z.object({ title: z.string().trim().max(200).optional().nullable(), draftText: z.string().max(200000).optional().nullable(), requestBadgeLabel: z.string().trim().max(120).optional().nullable(), codexModel: z.string().trim().max(120).optional().nullable(), chatTypeId: z.string().trim().max(120).optional().nullable(), lastChatTypeId: z.string().trim().max(120).optional().nullable(), generalSectionName: z.string().trim().max(120).optional().nullable(), contextLabel: z.string().trim().max(200).optional().nullable(), contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional().nullable(), notifyOffline: z.boolean().optional(), }).parse(request.body ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const current = await getChatConversation(params.sessionId, clientId || null); if (!current) { return reply.code(404).send({ message: '수정할 채팅방을 찾을 수 없습니다.', }); } const item = await updateChatConversationContext(params.sessionId, { title: Object.prototype.hasOwnProperty.call(payload, 'title') ? payload.title ?? null : undefined, draftText: Object.prototype.hasOwnProperty.call(payload, 'draftText') ? payload.draftText ?? '' : undefined, requestBadgeLabel: Object.prototype.hasOwnProperty.call(payload, 'requestBadgeLabel') ? payload.requestBadgeLabel ?? null : undefined, codexModel: Object.prototype.hasOwnProperty.call(payload, 'codexModel') ? payload.codexModel ?? null : undefined, clientId: current.clientId, chatTypeId: Object.prototype.hasOwnProperty.call(payload, 'chatTypeId') ? payload.chatTypeId ?? null : undefined, lastChatTypeId: Object.prototype.hasOwnProperty.call(payload, 'lastChatTypeId') ? payload.lastChatTypeId ?? null : undefined, generalSectionName: Object.prototype.hasOwnProperty.call(payload, 'generalSectionName') ? payload.generalSectionName ?? null : undefined, contextLabel: Object.prototype.hasOwnProperty.call(payload, 'contextLabel') ? payload.contextLabel ?? null : undefined, contextDescription: Object.prototype.hasOwnProperty.call(payload, 'contextDescription') ? payload.contextDescription ?? null : undefined, notifyOffline: Object.prototype.hasOwnProperty.call(payload, 'notifyOffline') ? payload.notifyOffline : undefined, }); return { ok: true, item, }; }); app.delete('/api/chat/conversations/:sessionId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const current = await getChatConversation(params.sessionId, clientId || null); if (!current) { return reply.code(404).send({ message: '삭제할 채팅방을 찾을 수 없습니다.', }); } await getActiveChatService()?.forgetSession(params.sessionId); const deleted = await deleteChatConversation(params.sessionId); return { ok: true, deleted, sessionId: params.sessionId, }; }); app.post('/api/chat/conversations/:sessionId/clear', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const current = await getChatConversation(params.sessionId, clientId || null); if (!current) { return reply.code(404).send({ message: '초기화할 채팅방을 찾을 수 없습니다.', }); } getActiveChatService()?.resetSessionData(params.sessionId); chatRuntimeService.clearSession(params.sessionId); const item = await clearChatConversationData(params.sessionId, clientId || null); if (!item) { return reply.code(404).send({ message: '채팅방 데이터 초기화 후 다시 불러오지 못했습니다.', }); } return { ok: true, item, }; }); }