diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts index 24d61d0..4954092 100644 --- a/src/routes/handlers/callback-handler.ts +++ b/src/routes/handlers/callback-handler.ts @@ -119,6 +119,122 @@ ${result.error} return; } + // 서버 주문 확인 + if (data.startsWith('server_order:')) { + const parts = data.split(':'); + if (parts.length !== 3) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); + return; + } + + const userId = parts[1]; + const index = parseInt(parts[2], 10); + + if (isNaN(index) || index < 0 || index > 2) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' }); + return; + } + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '처리 중...' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '⏳ 서버 주문 처리 중...' + ); + + // 세션 조회 + const { getServerSession, deleteServerSession } = await import('../../server-agent'); + + if (!env.SESSION_KV) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 세션 저장소가 설정되지 않았습니다.' + ); + return; + } + + const session = await getServerSession(env.SESSION_KV, userId); + + if (!session || !session.lastRecommendation) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.' + ); + return; + } + + const selected = session.lastRecommendation.recommendations[index]; + + if (!selected) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 선택한 서버를 찾을 수 없습니다.' + ); + await deleteServerSession(env.SESSION_KV, userId); + return; + } + + // 주문 처리 (현재는 준비 중) + const { executeServerAction } = await import('../../tools/server-tool'); + + const result = await executeServerAction( + 'order', + { + server_id: selected.plan_name, // 임시 + region_code: selected.region.code, + label: `${session.collectedInfo.useCase || 'server'}-1` + }, + env, + userId + ); + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `📋 ${selected.plan_name} 신청\n\n${result}` + ); + + // 세션 삭제 + await deleteServerSession(env.SESSION_KV, userId); + return; + } + + // 서버 주문 취소 + if (data.startsWith('server_cancel:')) { + const parts = data.split(':'); + if (parts.length !== 2) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); + return; + } + + const userId = parts[1]; + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 서버 신청이 취소되었습니다.' + ); + + // 세션 삭제 + const { deleteServerSession } = await import('../../server-agent'); + + if (env.SESSION_KV) { + await deleteServerSession(env.SESSION_KV, userId); + } + + return; + } + // 알 수 없는 callback data await answerCallbackQuery(env.BOT_TOKEN, queryId); } diff --git a/src/routes/handlers/message-handler.ts b/src/routes/handlers/message-handler.ts index aaa6999..92af514 100644 --- a/src/routes/handlers/message-handler.ts +++ b/src/routes/handlers/message-handler.ts @@ -100,6 +100,17 @@ export async function handleMessage( { text: '❌ 취소', callback_data: 'domain_cancel' } ] ]); + } else if (result.keyboardData.type === 'server_order') { + const { userId, index } = result.keyboardData; + const confirmData = `server_order:${userId}:${index}`; + const cancelData = `server_cancel:${userId}`; + + await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [ + [ + { text: '✅ 신청하기', callback_data: confirmData }, + { text: '❌ 취소', callback_data: cancelData } + ] + ]); } else { // TypeScript exhaustiveness check - should never reach here console.warn('[Webhook] Unknown keyboard type:', (result.keyboardData as { type: string }).type); diff --git a/src/server-agent.ts b/src/server-agent.ts index bd58dc4..28dc540 100644 --- a/src/server-agent.ts +++ b/src/server-agent.ts @@ -5,6 +5,7 @@ * - 대화형 서버 추천 상담 * - 세션 기반 정보 수집 * - 충분한 정보 수집 시 자동 추천 + * - 추천 후 사용자 선택 및 주문 흐름 * - Brave Search / Context7 도구로 최신 트렌드 반영 */ @@ -390,6 +391,56 @@ export async function processServerConsultation( status: session.status }); + // 선택 단계 처리 + if (session.status === 'selecting' && session.lastRecommendation) { + const selectionMatch = userMessage.match(/(\d+)(?:번|번째)?|첫\s*번째|두\s*번째|세\s*번째/); + + if (selectionMatch) { + let selectedIndex = -1; + + // 숫자 추출 + if (selectionMatch[1]) { + selectedIndex = parseInt(selectionMatch[1], 10) - 1; + } else if (userMessage.includes('첫')) { + selectedIndex = 0; + } else if (userMessage.includes('두')) { + selectedIndex = 1; + } else if (userMessage.includes('세')) { + selectedIndex = 2; + } + + // 유효성 검증 + if (selectedIndex >= 0 && selectedIndex < session.lastRecommendation.recommendations.length) { + const selected = session.lastRecommendation.recommendations[selectedIndex]; + + // Mark session as ordering + session.status = 'ordering'; + await saveServerSession(env.SESSION_KV, session.telegramUserId, session); + + // 주문 확인 메시지 생성 (인라인 버튼 포함) + const keyboardData = JSON.stringify({ + type: 'server_order', + userId: session.telegramUserId, + index: selectedIndex, + plan: selected.plan_name + }); + + return `🖥️ ${selected.plan_name} 신청 확인\n\n` + + `• 제공사: ${selected.provider}\n` + + `• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB / ${selected.specs.storage_gb}GB\n` + + `• 리전: ${selected.region.name} (${selected.region.code})\n` + + `• 가격: ₩${selected.price.monthly_krw.toLocaleString()}/월\n\n` + + `신청하시겠습니까?\n\n` + + `__KEYBOARD__${keyboardData}__END__`; + } else { + return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`; + } + } + + // 선택하지 않고 다른 질문을 한 경우 + return '서버 번호를 선택해주세요. (예: 1번)\n또는 "취소"라고 말씀하시면 처음부터 다시 시작합니다.'; + } + // Add user message to history session.messages.push({ role: 'user', content: userMessage }); @@ -410,7 +461,7 @@ export async function processServerConsultation( // Call recommendation API logger.info('추천 API 호출', { collectedInfo: session.collectedInfo }); - const { executeServerAction } = await import('./tools/server-tool'); + const { executeServerAction, getRecommendationData } = await import('./tools/server-tool'); const techStack = session.collectedInfo.useCase ? inferTechStack(session.collectedInfo.useCase) @@ -420,8 +471,7 @@ export async function processServerConsultation( ? inferExpectedUsers(session.collectedInfo.scale) : 100; - const recommendation = await executeServerAction( - 'recommend', + const recommendationData = await getRecommendationData( { tech_stack: techStack, expected_users: expectedUsers, @@ -430,15 +480,60 @@ export async function processServerConsultation( budget_limit: session.collectedInfo.budgetLimit, lang: 'ko', }, - env, - session.telegramUserId + env ); - // Mark session as completed and delete - session.status = 'completed'; - await deleteServerSession(env.SESSION_KV, session.telegramUserId); + // 추천 결과를 세션에 저장 + if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) { + session.lastRecommendation = { + recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ + plan_name: rec.server.instance_name, + provider: rec.server.provider_name, + specs: { + vcpu: rec.server.vcpu, + ram_gb: rec.server.memory_gb, + storage_gb: rec.server.storage_gb + }, + region: { + code: rec.server.region_code, + name: rec.server.region_name + }, + price: { + monthly_krw: Math.round(rec.server.monthly_price), + bandwidth_tb: rec.server.transfer_tb + }, + score: rec.score, + max_users: rec.estimated_capacity?.max_concurrent_users || 0 + })), + createdAt: Date.now() + }; - return `${aiResult.message}\n\n${recommendation}`; + // Mark session as selecting (사용자 선택 대기) + session.status = 'selecting'; + await saveServerSession(env.SESSION_KV, session.telegramUserId, session); + + const formattedRecommendation = await executeServerAction( + 'recommend', + { + tech_stack: techStack, + expected_users: expectedUsers, + use_case: session.collectedInfo.useCase || '웹 서비스', + region_preference: session.collectedInfo.regionPreference, + budget_limit: session.collectedInfo.budgetLimit, + lang: 'ko', + }, + env, + session.telegramUserId + ); + + return `${aiResult.message}\n\n${formattedRecommendation}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; + } else { + // 추천 결과 없음 - 세션 삭제 + session.status = 'completed'; + await deleteServerSession(env.SESSION_KV, session.telegramUserId); + + return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; + } } else { // Continue gathering information session.status = 'gathering'; diff --git a/src/tools/server-tool.ts b/src/tools/server-tool.ts index 2c86ce6..744dd8a 100644 --- a/src/tools/server-tool.ts +++ b/src/tools/server-tool.ts @@ -259,6 +259,51 @@ async function callCloudOrchestratorApi( } } +// 추천 데이터 조회 (포맷팅 없이 원본 반환) +export async function getRecommendationData( + args: { + tech_stack: string[]; + expected_users: number; + use_case: string; + traffic_pattern?: string; + region_preference?: string[]; + budget_limit?: number; + lang?: string; + }, + env?: Env +): Promise { + const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args; + + // 언어 자동 감지 + const detectedLang = lang || detectLanguage(use_case); + + // CDN 캐시 히트율 추정 + const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case); + + // API 요청 body 구성 + const requestBody: Record = { + tech_stack, + expected_users, + use_case, + lang: detectedLang, + }; + + if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern; + if (region_preference) requestBody.region_preference = region_preference; + if (budget_limit) requestBody.budget_limit = budget_limit; + if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate; + + // API 호출 + const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env); + + if (isErrorResult(result)) { + logger.error('추천 데이터 조회 실패', new Error(result.error)); + return null; + } + + return result as RecommendResponse; +} + // 서버 추천 결과 포맷팅 (필수 정보만) // __DIRECT__ 마커로 AI 재해석 없이 바로 반환 function formatRecommendations(data: RecommendResponse): string { diff --git a/src/types.ts b/src/types.ts index cba0bbf..ab234d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -224,7 +224,7 @@ export interface ManageServerArgs { // Server Consultation Session export interface ServerSession { telegramUserId: string; - status: 'gathering' | 'recommending' | 'completed'; + status: 'gathering' | 'recommending' | 'selecting' | 'ordering' | 'completed'; collectedInfo: { useCase?: string; scale?: 'personal' | 'business'; @@ -234,6 +234,18 @@ export interface ServerSession { messages: Array<{ role: 'user' | 'assistant'; content: string }>; createdAt: number; updatedAt: number; + lastRecommendation?: { + recommendations: Array<{ + plan_name: string; + provider: string; + specs: { vcpu: number; ram_gb: number; storage_gb: number }; + region: { code: string; name: string }; + price: { monthly_krw: number; bandwidth_tb: number }; + score: number; + max_users: number; + }>; + createdAt: number; + }; } // Deposit Agent 결과 타입 @@ -381,7 +393,14 @@ export interface DomainRegisterKeyboardData { price: number; } -export type KeyboardData = DomainRegisterKeyboardData; +export interface ServerOrderKeyboardData { + type: "server_order"; + userId: string; + index: number; // recommendations 배열 인덱스 + plan: string; // 플랜 이름 +} + +export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData; // Workers AI Types (from worker-configuration.d.ts) export type WorkersAIModel =