feat: add server selection and order flow after recommendation

- Add 'selecting' and 'ordering' status to ServerSession
- Add lastRecommendation field to store recommendation results
- Keep session alive after recommendation (don't delete immediately)
- Add selection pattern matching (1번, 첫번째, 1번 선택 등)
- Add order confirmation message with inline buttons
- Add server_order/server_cancel callback handlers
- Add ServerOrderKeyboardData type for button data

Flow: recommend → select number → confirm with buttons → order/cancel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-27 10:45:04 +09:00
parent e4ccff9f87
commit 0c3bfede7d
5 changed files with 297 additions and 11 deletions

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -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<RecommendResponse | null> {
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<string, unknown> = {
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 {

View File

@@ -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 =