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;
|
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
|
// 알 수 없는 callback data
|
||||||
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ export async function handleMessage(
|
|||||||
{ text: '❌ 취소', callback_data: 'domain_cancel' }
|
{ 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 {
|
} else {
|
||||||
// TypeScript exhaustiveness check - should never reach here
|
// TypeScript exhaustiveness check - should never reach here
|
||||||
console.warn('[Webhook] Unknown keyboard type:', (result.keyboardData as { type: string }).type);
|
console.warn('[Webhook] Unknown keyboard type:', (result.keyboardData as { type: string }).type);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* - 대화형 서버 추천 상담
|
* - 대화형 서버 추천 상담
|
||||||
* - 세션 기반 정보 수집
|
* - 세션 기반 정보 수집
|
||||||
* - 충분한 정보 수집 시 자동 추천
|
* - 충분한 정보 수집 시 자동 추천
|
||||||
|
* - 추천 후 사용자 선택 및 주문 흐름
|
||||||
* - Brave Search / Context7 도구로 최신 트렌드 반영
|
* - Brave Search / Context7 도구로 최신 트렌드 반영
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -390,6 +391,56 @@ export async function processServerConsultation(
|
|||||||
status: session.status
|
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
|
// Add user message to history
|
||||||
session.messages.push({ role: 'user', content: userMessage });
|
session.messages.push({ role: 'user', content: userMessage });
|
||||||
|
|
||||||
@@ -410,7 +461,7 @@ export async function processServerConsultation(
|
|||||||
// Call recommendation API
|
// Call recommendation API
|
||||||
logger.info('추천 API 호출', { collectedInfo: session.collectedInfo });
|
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
|
const techStack = session.collectedInfo.useCase
|
||||||
? inferTechStack(session.collectedInfo.useCase)
|
? inferTechStack(session.collectedInfo.useCase)
|
||||||
@@ -420,7 +471,48 @@ export async function processServerConsultation(
|
|||||||
? inferExpectedUsers(session.collectedInfo.scale)
|
? inferExpectedUsers(session.collectedInfo.scale)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
const recommendation = await executeServerAction(
|
const recommendationData = await getRecommendationData(
|
||||||
|
{
|
||||||
|
tech_stack: techStack,
|
||||||
|
expected_users: expectedUsers,
|
||||||
|
use_case: session.collectedInfo.useCase || '웹 서비스',
|
||||||
|
region_preference: session.collectedInfo.regionPreference,
|
||||||
|
budget_limit: session.collectedInfo.budgetLimit,
|
||||||
|
lang: 'ko',
|
||||||
|
},
|
||||||
|
env
|
||||||
|
);
|
||||||
|
|
||||||
|
// 추천 결과를 세션에 저장
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark session as selecting (사용자 선택 대기)
|
||||||
|
session.status = 'selecting';
|
||||||
|
await saveServerSession(env.SESSION_KV, session.telegramUserId, session);
|
||||||
|
|
||||||
|
const formattedRecommendation = await executeServerAction(
|
||||||
'recommend',
|
'recommend',
|
||||||
{
|
{
|
||||||
tech_stack: techStack,
|
tech_stack: techStack,
|
||||||
@@ -434,11 +526,14 @@ export async function processServerConsultation(
|
|||||||
session.telegramUserId
|
session.telegramUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark session as completed and delete
|
return `${aiResult.message}\n\n${formattedRecommendation}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
|
||||||
|
} else {
|
||||||
|
// 추천 결과 없음 - 세션 삭제
|
||||||
session.status = 'completed';
|
session.status = 'completed';
|
||||||
await deleteServerSession(env.SESSION_KV, session.telegramUserId);
|
await deleteServerSession(env.SESSION_KV, session.telegramUserId);
|
||||||
|
|
||||||
return `${aiResult.message}\n\n${recommendation}`;
|
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Continue gathering information
|
// Continue gathering information
|
||||||
session.status = 'gathering';
|
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 재해석 없이 바로 반환
|
// __DIRECT__ 마커로 AI 재해석 없이 바로 반환
|
||||||
function formatRecommendations(data: RecommendResponse): string {
|
function formatRecommendations(data: RecommendResponse): string {
|
||||||
|
|||||||
23
src/types.ts
23
src/types.ts
@@ -224,7 +224,7 @@ export interface ManageServerArgs {
|
|||||||
// Server Consultation Session
|
// Server Consultation Session
|
||||||
export interface ServerSession {
|
export interface ServerSession {
|
||||||
telegramUserId: string;
|
telegramUserId: string;
|
||||||
status: 'gathering' | 'recommending' | 'completed';
|
status: 'gathering' | 'recommending' | 'selecting' | 'ordering' | 'completed';
|
||||||
collectedInfo: {
|
collectedInfo: {
|
||||||
useCase?: string;
|
useCase?: string;
|
||||||
scale?: 'personal' | 'business';
|
scale?: 'personal' | 'business';
|
||||||
@@ -234,6 +234,18 @@ export interface ServerSession {
|
|||||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: 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 결과 타입
|
// Deposit Agent 결과 타입
|
||||||
@@ -381,7 +393,14 @@ export interface DomainRegisterKeyboardData {
|
|||||||
price: number;
|
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)
|
// Workers AI Types (from worker-configuration.d.ts)
|
||||||
export type WorkersAIModel =
|
export type WorkersAIModel =
|
||||||
|
|||||||
Reference in New Issue
Block a user