4333 lines
150 KiB
TypeScript
4333 lines
150 KiB
TypeScript
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<ChatShareTokenPayload>;
|
|
|
|
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<ReturnType<typeof resolveManagedChatShareContext>>['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<ReturnType<typeof resolveManagedChatShareContext>>['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<Awaited<ReturnType<typeof buildChatShareSnapshot>>>;
|
|
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<string, unknown>, options?: { allowOptionalSessionId?: boolean }) {
|
|
const sessionId = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-session-id']);
|
|
const fileName = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-file-name']);
|
|
const mimeType = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-mime-type']);
|
|
const schema = options?.allowOptionalSessionId
|
|
? z.object({
|
|
sessionId: z.string().trim().max(120).regex(/^[A-Za-z0-9._:-]+$/).optional().nullable(),
|
|
fileName: z.string().trim().max(255).optional(),
|
|
mimeType: z.string().trim().max(200).optional(),
|
|
})
|
|
: z.object({
|
|
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
|
fileName: z.string().trim().max(255).optional(),
|
|
mimeType: z.string().trim().max(200).optional(),
|
|
});
|
|
|
|
return schema.parse({
|
|
sessionId: sessionId || (options?.allowOptionalSessionId ? null : ''),
|
|
fileName: fileName || undefined,
|
|
mimeType: mimeType || undefined,
|
|
});
|
|
}
|
|
|
|
function isOctetStreamRequest(contentTypeHeader: unknown) {
|
|
const contentType = String(Array.isArray(contentTypeHeader) ? contentTypeHeader[0] ?? '' : contentTypeHeader ?? '').toLowerCase();
|
|
return contentType.startsWith('application/octet-stream');
|
|
}
|
|
|
|
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
|
const raw = request.headers['x-client-id'];
|
|
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
|
}
|
|
|
|
function canViewAllConversations(request: { headers: Record<string, unknown> }) {
|
|
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<ReturnType<typeof listChatConversationRequests>>[number];
|
|
type ListedChatConversationMessage = Awaited<ReturnType<typeof listChatConversationMessages>>[number];
|
|
type ListedChatConversationActivityLog = Awaited<ReturnType<typeof listChatConversationActivityLogsByRequestIds>>[number];
|
|
|
|
function resolveShareRootRequestId(
|
|
requestId: string,
|
|
requestMap: Map<string, ListedChatConversationRequest>,
|
|
kind: ChatShareKind,
|
|
) {
|
|
const normalizedRequestId = requestId.trim();
|
|
|
|
if (!normalizedRequestId || kind !== 'request-bundle') {
|
|
return normalizedRequestId;
|
|
}
|
|
|
|
const visitedRequestIds = new Set<string>();
|
|
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<string, string[]>();
|
|
|
|
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<string, string[]>,
|
|
) {
|
|
const normalizedRootRequestId = rootRequestId.trim();
|
|
|
|
if (!normalizedRootRequestId) {
|
|
return [];
|
|
}
|
|
|
|
const orderedRequestIds: string[] = [];
|
|
const pendingRequestIds = [normalizedRootRequestId];
|
|
const visitedRequestIds = new Set<string>();
|
|
|
|
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<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>;
|
|
}> = (message.parts ?? []).flatMap((part: NonNullable<ListedChatConversationMessage['parts']>[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<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>,
|
|
) {
|
|
return part.readOnly === true || part.resolvedBy != null;
|
|
}
|
|
|
|
function hasUnresolvedPromptPart(message: ListedChatConversationMessage) {
|
|
const promptParts = (message.parts ?? []).filter(
|
|
(
|
|
part: NonNullable<ListedChatConversationMessage['parts']>[number],
|
|
): part is Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }> => part.type === 'prompt',
|
|
);
|
|
|
|
return promptParts.some(
|
|
(part: Extract<NonNullable<ListedChatConversationMessage['parts']>[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<ListedChatConversationMessage['parts']>[number],
|
|
): part is Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }> => part.type === 'prompt',
|
|
);
|
|
|
|
return count + promptParts.filter(
|
|
(part: Extract<NonNullable<ListedChatConversationMessage['parts']>[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<string, number>();
|
|
|
|
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<string, number>();
|
|
|
|
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<string, number>,
|
|
promptFollowupCountByParentId?: Map<string, number>,
|
|
) {
|
|
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<string, ListedChatConversationMessage[]>();
|
|
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<ReturnType<typeof getChatConversation>>) {
|
|
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<ReturnType<typeof getChatConversation>>;
|
|
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<ListedChatConversationMessage['parts'][number], { type: 'prompt' }>;
|
|
}
|
|
| null;
|
|
detailLevel: 'full' | 'initial';
|
|
};
|
|
}
|
|
|
|
function resolveRecoveredShareParentRequestId(
|
|
shareSnapshot: Awaited<ReturnType<typeof buildChatShareSnapshot>>,
|
|
requestedParentRequestId: string | null | undefined,
|
|
fallbackRequestIds: Array<string | null | undefined> = [],
|
|
) {
|
|
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<TokenSettingRecord, 'maxTokensPer7Days' | 'maxTokensPer5Hours'>;
|
|
|
|
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<typeof chatRuntimeService.getSnapshot>,
|
|
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<ReturnType<typeof getSharedResourceTokenDetailBySharePath>>
|
|
| 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<string, string | string[] | undefined>;
|
|
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<string, unknown>) : null;
|
|
const queryPin = queryRecord && typeof queryRecord.sharePin === 'string' ? queryRecord.sharePin : '';
|
|
return queryPin.trim();
|
|
}
|
|
|
|
function getRequestClientId(request: {
|
|
headers: Record<string, string | string[] | undefined>;
|
|
query?: unknown;
|
|
}) {
|
|
const headerClientId = getClientIdHeader(request as { headers: Record<string, unknown> });
|
|
|
|
if (headerClientId) {
|
|
return headerClientId;
|
|
}
|
|
|
|
const queryRecord = request.query && typeof request.query === 'object' ? (request.query as Record<string, unknown>) : null;
|
|
const queryClientId = queryRecord && typeof queryRecord.clientId === 'string' ? queryRecord.clientId : '';
|
|
return queryClientId.trim();
|
|
}
|
|
|
|
async function ensureManagedShareAccessPin(
|
|
request: { headers: Record<string, string | string[] | undefined>; 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<ReturnType<typeof resolveManagedChatShareContext>>['managedResource']
|
|
| null
|
|
| undefined,
|
|
permission: 'manage',
|
|
) {
|
|
return managedResource?.token.permissions?.includes(permission) === true;
|
|
}
|
|
|
|
function hasManagedShareAllowedApp(
|
|
managedResource:
|
|
| Awaited<ReturnType<typeof resolveManagedChatShareContext>>['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,
|
|
};
|
|
});
|
|
}
|