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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
23
src/types.ts
23
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 =
|
||||
|
||||
Reference in New Issue
Block a user