feat: add server ordering system with session-based flow
- Add server recommendation integration (SERVER_RECOMMEND worker) - Implement KV-based session management for multi-step ordering - Add Linode/Vultr API clients for server provisioning - Add server-tool for Function Calling support refactor: major code reorganization (Phase 1-3) - Remove 443 lines of deprecated callback handlers - Extract handlers to separate files (message-handler, callback-handler) - Extract cloud-spec-service, server-recommend-service - Centralize constants (OS_IMAGES, REGION_FLAGS, NUM_EMOJIS) - webhook.ts reduced from 1,951 to 30 lines Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
758
src/routes/handlers/callback-handler.ts
Normal file
758
src/routes/handlers/callback-handler.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
import { answerCallbackQuery, editMessageText, sendMessage, sendMessageWithKeyboard } from '../../telegram';
|
||||
import { UserService } from '../../services/user-service';
|
||||
import { executeDomainRegister } from '../../domain-register';
|
||||
import { executeServerProvision } from '../../server-provision';
|
||||
import {
|
||||
getSessionForUser,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createSession,
|
||||
ServerOrderSessionData,
|
||||
} from '../../utils/session';
|
||||
import { getServerSpec } from '../../services/cloud-spec-service';
|
||||
import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server';
|
||||
import type { Env, TelegramUpdate } from '../../types';
|
||||
|
||||
/**
|
||||
* Safely parse integer with range validation
|
||||
* @param value - String to parse
|
||||
* @param min - Minimum allowed value (inclusive)
|
||||
* @param max - Maximum allowed value (inclusive)
|
||||
* @returns Parsed integer or null if invalid/out of range
|
||||
*/
|
||||
function parseIntSafe(value: string, min: number, max: number): number | null {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed) || parsed < min || parsed > max) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback Query 처리 (인라인 버튼 클릭)
|
||||
*/
|
||||
export async function handleCallbackQuery(
|
||||
env: Env,
|
||||
callbackQuery: TelegramUpdate['callback_query']
|
||||
): Promise<void> {
|
||||
if (!callbackQuery) return;
|
||||
|
||||
const { id: queryId, from, message, data } = callbackQuery;
|
||||
if (!data || !message) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = message.chat.id;
|
||||
const messageId = message.message_id;
|
||||
const telegramUserId = from.id.toString();
|
||||
|
||||
const userService = new UserService(env.DB);
|
||||
const user = await userService.getUserByTelegramId(telegramUserId);
|
||||
|
||||
if (!user) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 도메인 등록 처리
|
||||
if (data.startsWith('domain_reg:')) {
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 3) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = parts[1];
|
||||
const price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW
|
||||
|
||||
if (price === null) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`⏳ <b>${domain}</b> 등록 처리 중...`
|
||||
);
|
||||
|
||||
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
|
||||
|
||||
if (result.success) {
|
||||
const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : '';
|
||||
const nsInfo = result.nameservers && result.nameservers.length > 0
|
||||
? `\n\n🌐 <b>현재 네임서버:</b>\n${result.nameservers.map(ns => `• <code>${ns}</code>`).join('\n')}`
|
||||
: '';
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`✅ <b>도메인 등록 완료!</b>
|
||||
|
||||
• 도메인: <code>${result.domain}</code>
|
||||
• 결제 금액: ${result.price?.toLocaleString()}원
|
||||
• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo}
|
||||
|
||||
🎉 축하합니다! 도메인이 성공적으로 등록되었습니다.
|
||||
네임서버 변경이 필요하면 말씀해주세요.`
|
||||
);
|
||||
} else {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>등록 실패</b>
|
||||
|
||||
${result.error}
|
||||
|
||||
다시 시도하시려면 도메인 등록을 요청해주세요.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 도메인 등록 취소
|
||||
if (data === 'domain_cancel') {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
'❌ 도메인 등록이 취소되었습니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 세션 기반 서버 플로우 =====
|
||||
if (data.startsWith('srv:')) {
|
||||
const parts = data.split(':');
|
||||
const sessionId = parts[1];
|
||||
const action = parts[2];
|
||||
|
||||
// 세션 조회 + 권한 검증
|
||||
const session = await getSessionForUser<ServerOrderSessionData>(
|
||||
env.SESSION_KV,
|
||||
sessionId,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, {
|
||||
text: '세션이 만료되었습니다. 다시 시작해주세요.'
|
||||
});
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId,
|
||||
'⏰ 세션이 만료되었습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// select: 사양 선택 (추천 목록에서)
|
||||
if (action === 'select') {
|
||||
const index = parseInt(parts[3], 10);
|
||||
const recs = session.data.recommendations;
|
||||
|
||||
if (!recs || index < 0 || index >= recs.length) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = recs[index];
|
||||
|
||||
// 세션 업데이트
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'spec_confirm',
|
||||
plan: selected.plan,
|
||||
region: selected.region,
|
||||
provider: selected.provider,
|
||||
recommendations: undefined // 선택 후 목록 삭제
|
||||
});
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 조회 중...' });
|
||||
|
||||
// CLOUD_DB에서 상세 조회
|
||||
const spec = await getServerSpec(
|
||||
env.CLOUD_DB,
|
||||
selected.plan,
|
||||
selected.region,
|
||||
selected.provider
|
||||
);
|
||||
|
||||
if (!spec) {
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 가격 정보 세션에 저장
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
priceKrw: spec.monthly_price_krw
|
||||
});
|
||||
|
||||
const ramGB = (spec.memory_mb / 1024).toFixed(1);
|
||||
const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유';
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`📦 <b>서버 사양 확인</b>
|
||||
|
||||
<b>컴퓨팅</b>
|
||||
• vCPU: ${spec.vcpu}개
|
||||
• RAM: ${ramGB}GB
|
||||
• 스토리지: ${spec.storage_gb}GB SSD
|
||||
|
||||
<b>네트워크</b>
|
||||
• 트래픽: ${spec.transfer_tb}TB/월
|
||||
• 대역폭: ${networkSpeed}
|
||||
|
||||
<b>요금</b>
|
||||
• 월 ${spec.monthly_price_krw.toLocaleString()}원
|
||||
|
||||
이 사양으로 진행하시겠습니까?`,
|
||||
{
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }],
|
||||
[
|
||||
{ text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` },
|
||||
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// os_list: OS 선택 화면
|
||||
if (action === 'os_list') {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택' });
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'os_select'
|
||||
});
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`🖥️ <b>OS 선택</b>\n\n서버에 설치할 운영체제를 선택하세요:`,
|
||||
{
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }],
|
||||
[{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }],
|
||||
[{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }],
|
||||
[{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }],
|
||||
[
|
||||
{ text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` },
|
||||
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// os: OS 선택 완료 → 주문 생성 + 최종 확인
|
||||
if (action === 'os') {
|
||||
const osImage = parts[3];
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'final_confirm',
|
||||
image: osImage
|
||||
});
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 생성 중...' });
|
||||
|
||||
const { plan, region, provider } = session.data;
|
||||
|
||||
// DB에서 사양 조회
|
||||
const spec = await getServerSpec(
|
||||
env.CLOUD_DB,
|
||||
plan!,
|
||||
region!,
|
||||
provider!
|
||||
);
|
||||
|
||||
if (!spec) {
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 잔액 확인
|
||||
const deposit = await env.DB.prepare(
|
||||
"SELECT balance FROM user_deposits WHERE user_id = ?"
|
||||
).bind(user.id).first<{ balance: number }>();
|
||||
const balance = deposit?.balance || 0;
|
||||
|
||||
if (balance < spec.monthly_price_krw) {
|
||||
const shortage = spec.monthly_price_krw - balance;
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>잔액 부족</b>
|
||||
|
||||
• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
• 부족 금액: ${shortage.toLocaleString()}원
|
||||
|
||||
💳 <b>입금 계좌</b>
|
||||
하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||
입금 후 다시 시도해주세요.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 주문 생성
|
||||
const label = `server-${Date.now()}`;
|
||||
const orderResult = await env.DB.prepare(`
|
||||
INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now'))
|
||||
`).bind(user.id, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run();
|
||||
|
||||
const orderId = orderResult.meta?.last_row_id;
|
||||
|
||||
if (!orderId) {
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 주문 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
// OS 이름 변환
|
||||
const ramGB = (spec.memory_mb / 1024).toFixed(1);
|
||||
const specStr = `${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD`;
|
||||
|
||||
// 최종 확인 화면
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`✅ <b>최종 확인</b>
|
||||
|
||||
• 사양: <b>${specStr}</b>
|
||||
• OS: ${getOSDisplayName(osImage)}
|
||||
• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
|
||||
💡 <b>요금 안내</b>
|
||||
• 월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다.
|
||||
• 예: 10일 사용 후 해지 → (시간당 요금 × 사용 시간) 차감 후 잔액 환불`,
|
||||
{
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '✅ 서버 생성', callback_data: `srv:${sessionId}:confirm` }],
|
||||
[
|
||||
{ text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_os` },
|
||||
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// confirm: 서버 생성 실행
|
||||
if (action === 'confirm') {
|
||||
const { orderId } = session.data;
|
||||
if (!orderId) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 정보가 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' });
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId,
|
||||
'⏳ 서버를 생성하고 있습니다... (1-3분 소요)'
|
||||
);
|
||||
|
||||
const result = await executeServerProvision(env, user.id, telegramUserId, orderId);
|
||||
|
||||
// 세션 삭제
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
|
||||
if (result.success) {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`✅ <b>서버 생성 완료!</b>
|
||||
|
||||
• 사양: <code>${result.plan_label}</code>
|
||||
• 리전: ${result.region}
|
||||
• IP 주소: <code>${result.ip_address}</code>
|
||||
• Root 비밀번호: <code>${result.root_password}</code>
|
||||
|
||||
📌 <b>접속 방법</b>
|
||||
<code>ssh root@${result.ip_address}</code>
|
||||
|
||||
⚠️ <b>보안 권고</b>
|
||||
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
|
||||
2. SSH 키 인증 설정을 권장합니다.
|
||||
3. 방화벽(UFW)을 활성화하세요.
|
||||
|
||||
🎉 서버가 성공적으로 생성되었습니다!`
|
||||
);
|
||||
} else {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>서버 생성 실패</b>
|
||||
|
||||
${result.error}
|
||||
|
||||
다시 시도하시려면 서버 주문을 요청해주세요.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// cancel: 취소
|
||||
if (action === 'cancel') {
|
||||
const { orderId } = session.data;
|
||||
|
||||
// pending 주문 있으면 삭제
|
||||
if (orderId) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'"
|
||||
).bind(orderId, user.id).run();
|
||||
}
|
||||
|
||||
// 세션 삭제
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId,
|
||||
'❌ 서버 선택이 취소되었습니다.\n\n다시 추천받으시려면 "서버 추천해줘"라고 말씀해주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// reselect: 다른 사양 선택 (다시 추천 API 호출)
|
||||
if (action === 'reselect') {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '다시 추천받는 중...' });
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId, '🔄 다시 추천받는 중...');
|
||||
|
||||
// 기존 세션 삭제
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
|
||||
try {
|
||||
// SERVER_RECOMMEND 서비스로 기본 추천 요청
|
||||
const requestBody = {
|
||||
tech_stack: ['nginx'],
|
||||
expected_users: 100,
|
||||
use_case: 'general purpose server',
|
||||
lang: 'ko'
|
||||
};
|
||||
|
||||
const response = env.SERVER_RECOMMEND
|
||||
? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
: await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const apiResult = await response.json() as {
|
||||
recommendations?: Array<{
|
||||
server: {
|
||||
instance_id: string;
|
||||
region_code: string;
|
||||
provider_name: string;
|
||||
vcpu: number;
|
||||
memory_mb: number;
|
||||
storage_gb: number;
|
||||
monthly_price: number;
|
||||
};
|
||||
score: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!apiResult.recommendations || apiResult.recommendations.length === 0) {
|
||||
throw new Error('No recommendations');
|
||||
}
|
||||
|
||||
// 상위 5개 추천
|
||||
const topRecs = apiResult.recommendations.slice(0, 5);
|
||||
|
||||
let responseText = `🎯 <b>범용</b> 서버 추천\n\n`;
|
||||
topRecs.forEach((rec, index) => {
|
||||
const server = rec.server;
|
||||
const ramGB = (server.memory_mb / 1024).toFixed(1);
|
||||
const priceKrw = Math.round(server.monthly_price);
|
||||
const regionDisplay = getRegionDisplay(server.region_code);
|
||||
responseText += `${NUM_EMOJIS[index]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
|
||||
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`;
|
||||
});
|
||||
responseText += `👆 <b>서버를 신청하려면 아래 버튼을 선택하세요</b>\n\n💡 다른 조건을 원하시면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`;
|
||||
|
||||
// 새 세션 생성
|
||||
const newSessionId = await createSession<ServerOrderSessionData>(
|
||||
env.SESSION_KV,
|
||||
user.id,
|
||||
'server_order',
|
||||
{
|
||||
recommendations: topRecs.map(rec => ({
|
||||
plan: rec.server.instance_id,
|
||||
region: rec.server.region_code,
|
||||
provider: rec.server.provider_name.toLowerCase()
|
||||
}))
|
||||
},
|
||||
'recommend'
|
||||
);
|
||||
|
||||
// 버튼 생성
|
||||
const buttons = topRecs.map((_, index) => ({
|
||||
text: `${index + 1}번 선택`,
|
||||
callback_data: `srv:${newSessionId}:select:${index}`
|
||||
}));
|
||||
|
||||
const keyboard = [];
|
||||
if (buttons.length > 0) keyboard.push(buttons.slice(0, 3));
|
||||
if (buttons.length > 3) keyboard.push(buttons.slice(3));
|
||||
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, keyboard);
|
||||
} catch (error) {
|
||||
console.error('[srv:reselect] 추천 API 오류:', error);
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'❌ 추천을 다시 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 말씀해주세요.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// back_spec: 사양 확인으로 뒤로
|
||||
if (action === 'back_spec') {
|
||||
const { plan, region, provider } = session.data;
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'spec_confirm',
|
||||
image: undefined
|
||||
});
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 확인으로 이동' });
|
||||
|
||||
// 사양 상세 다시 조회 및 표시
|
||||
const spec = await getServerSpec(
|
||||
env.CLOUD_DB,
|
||||
plan!,
|
||||
region!,
|
||||
provider!
|
||||
);
|
||||
|
||||
if (!spec) {
|
||||
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ramGB = (spec.memory_mb / 1024).toFixed(1);
|
||||
const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유';
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`📦 <b>서버 사양 확인</b>
|
||||
|
||||
<b>컴퓨팅</b>
|
||||
• vCPU: ${spec.vcpu}개
|
||||
• RAM: ${ramGB}GB
|
||||
• 스토리지: ${spec.storage_gb}GB SSD
|
||||
|
||||
<b>네트워크</b>
|
||||
• 트래픽: ${spec.transfer_tb}TB/월
|
||||
• 대역폭: ${networkSpeed}
|
||||
|
||||
<b>요금</b>
|
||||
• 월 ${spec.monthly_price_krw.toLocaleString()}원
|
||||
|
||||
이 사양으로 진행하시겠습니까?`,
|
||||
{
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }],
|
||||
[
|
||||
{ text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` },
|
||||
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// back_os: OS 선택으로 뒤로
|
||||
if (action === 'back_os') {
|
||||
const { orderId } = session.data;
|
||||
|
||||
// pending 주문 삭제
|
||||
if (orderId) {
|
||||
await env.DB.prepare(
|
||||
"DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'"
|
||||
).bind(orderId, user.id).run();
|
||||
}
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'os_select',
|
||||
image: undefined,
|
||||
orderId: undefined
|
||||
});
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택으로 이동' });
|
||||
|
||||
// OS 선택 화면 표시
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`🖥️ <b>OS 선택</b>\n\n서버에 설치할 운영체제를 선택하세요:`,
|
||||
{
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }],
|
||||
[{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }],
|
||||
[{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }],
|
||||
[{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }],
|
||||
[
|
||||
{ text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` },
|
||||
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 알 수 없는 action
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 주문 확인
|
||||
if (data.startsWith('server_order:')) {
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 2) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = parseIntSafe(parts[1], 1, 2147483647); // Max INT
|
||||
|
||||
if (orderId === null) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
'⏳ 서버를 생성하고 있습니다... (1-3분 소요)'
|
||||
);
|
||||
|
||||
const result = await executeServerProvision(env, user.id, telegramUserId, orderId);
|
||||
|
||||
if (result.success) {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`✅ <b>서버 생성 완료!</b>
|
||||
|
||||
• 사양: <code>${result.plan_label}</code>
|
||||
• 리전: ${result.region}
|
||||
• IP 주소: <code>${result.ip_address}</code>
|
||||
• Root 비밀번호: <code>${result.root_password}</code>
|
||||
|
||||
📌 <b>접속 방법</b>
|
||||
<code>ssh root@${result.ip_address}</code>
|
||||
|
||||
⚠️ <b>보안 권고</b>
|
||||
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
|
||||
2. SSH 키 인증 설정을 권장합니다.
|
||||
3. 방화벽(UFW)을 활성화하세요.
|
||||
|
||||
🎉 서버가 성공적으로 생성되었습니다!`
|
||||
);
|
||||
} else {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>서버 생성 실패</b>
|
||||
|
||||
${result.error}
|
||||
|
||||
다시 시도하시려면 서버 주문을 요청해주세요.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 주문 취소
|
||||
if (data.startsWith('server_cancel:')) {
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 2) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = parseIntSafe(parts[1], 1, 2147483647);
|
||||
|
||||
if (orderId === null) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 주문 취소 처리 (DB에서 status를 cancelled로 변경)
|
||||
const cancelResult = await env.DB.prepare(
|
||||
"UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'"
|
||||
).bind(orderId, user.id).run();
|
||||
|
||||
if (cancelResult.success && cancelResult.meta?.changes && cancelResult.meta.changes > 0) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
'❌ 서버 주문이 취소되었습니다.'
|
||||
);
|
||||
} else {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소 실패 (이미 처리됨)' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
'⚠️ 주문 취소에 실패했습니다. (이미 처리되었거나 권한이 없습니다.)'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 이하 기존 핸들러는 레거시 주문 전용 (새로운 세션 기반 플로우는 srv:로 시작) =====
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
||||
}
|
||||
613
src/routes/handlers/message-handler.ts
Normal file
613
src/routes/handlers/message-handler.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import { sendMessage, sendMessageWithKeyboard } from '../../telegram';
|
||||
import { checkRateLimit } from '../../security';
|
||||
import { handleCommand } from '../../commands';
|
||||
import { UserService } from '../../services/user-service';
|
||||
import { ConversationService } from '../../services/conversation-service';
|
||||
import { ERROR_MESSAGES } from '../../constants/messages';
|
||||
import {
|
||||
createSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
getUserActiveSession,
|
||||
ServerOrderSessionData,
|
||||
} from '../../utils/session';
|
||||
import { getServerSpec } from '../../services/cloud-spec-service';
|
||||
import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server';
|
||||
import type { Env, TelegramUpdate } from '../../types';
|
||||
|
||||
/**
|
||||
* 메시지 처리 핸들러
|
||||
*/
|
||||
export async function handleMessage(
|
||||
env: Env,
|
||||
update: TelegramUpdate
|
||||
): Promise<void> {
|
||||
if (!update.message?.text) return;
|
||||
|
||||
const { message } = update;
|
||||
const chatId = message.chat.id;
|
||||
const chatIdStr = chatId.toString();
|
||||
const text = message.text!;
|
||||
const telegramUserId = message.from.id.toString();
|
||||
|
||||
// 1. Rate Limiting 체크
|
||||
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 서비스 인스턴스 초기화
|
||||
const userService = new UserService(env.DB);
|
||||
const conversationService = new ConversationService(env);
|
||||
|
||||
// 3. 사용자 조회/생성
|
||||
let userId: number;
|
||||
try {
|
||||
userId = await userService.getOrCreateUser(
|
||||
telegramUserId,
|
||||
message.from.first_name,
|
||||
message.from.username
|
||||
);
|
||||
} catch (dbError) {
|
||||
console.error('[handleMessage] 사용자 DB 오류:', dbError);
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
ERROR_MESSAGES.TEMPORARY_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. 세션 기반 대화형 서버 주문 플로우 처리
|
||||
const serverSession = await getUserActiveSession<ServerOrderSessionData>(
|
||||
env.SESSION_KV,
|
||||
userId,
|
||||
'server_order'
|
||||
);
|
||||
|
||||
if (serverSession) {
|
||||
const { sessionId, session } = serverSession;
|
||||
const { step } = session;
|
||||
const lowerText = text.toLowerCase().trim();
|
||||
|
||||
// 취소 패턴 (모든 단계에서)
|
||||
if (/^(취소|그만|중단|cancel|stop)/.test(lowerText)) {
|
||||
// pending 주문 있으면 취소
|
||||
if (session.data.orderId) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE server_orders SET status = 'cancelled' WHERE id = ? AND user_id = ? AND status = 'pending'"
|
||||
).bind(session.data.orderId, userId).run();
|
||||
}
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
'❌ 서버 주문이 취소되었습니다.\n\n다시 시작하려면 "서버 추천해줘"라고 말씀해주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: recommend - 추천 목록에서 선택
|
||||
if (step === 'recommend' && session.data.recommendations) {
|
||||
// 숫자 패턴: "1", "1번", "첫번째", "첫 번째"
|
||||
const numPatterns: Record<string, number> = {
|
||||
'1': 0, '1번': 0, '첫번째': 0, '첫 번째': 0, '일번': 0,
|
||||
'2': 1, '2번': 1, '두번째': 1, '두 번째': 1, '이번': 1,
|
||||
'3': 2, '3번': 2, '세번째': 2, '세 번째': 2, '삼번': 2,
|
||||
'4': 3, '4번': 3, '네번째': 3, '네 번째': 3, '사번': 3,
|
||||
'5': 4, '5번': 4, '다섯번째': 4, '다섯 번째': 4, '오번': 4,
|
||||
};
|
||||
|
||||
const matchedIndex = numPatterns[lowerText];
|
||||
if (matchedIndex !== undefined && matchedIndex < session.data.recommendations.length) {
|
||||
const selected = session.data.recommendations[matchedIndex];
|
||||
|
||||
// 세션 업데이트
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'spec_confirm',
|
||||
plan: selected.plan,
|
||||
region: selected.region,
|
||||
provider: selected.provider,
|
||||
recommendations: undefined
|
||||
});
|
||||
|
||||
// 사양 조회
|
||||
const spec = await getServerSpec(
|
||||
env.CLOUD_DB,
|
||||
selected.plan,
|
||||
selected.region,
|
||||
selected.provider
|
||||
);
|
||||
|
||||
if (!spec) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, '❌ 사양을 찾을 수 없습니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
priceKrw: spec.monthly_price_krw
|
||||
});
|
||||
|
||||
const ramGB = (spec.memory_mb / 1024).toFixed(1);
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`📦 <b>${matchedIndex + 1}번 사양 선택</b>
|
||||
|
||||
<b>컴퓨팅</b>
|
||||
• vCPU: ${spec.vcpu}개
|
||||
• RAM: ${ramGB}GB
|
||||
• 스토리지: ${spec.storage_gb}GB SSD
|
||||
• 트래픽: ${spec.transfer_tb}TB/월
|
||||
|
||||
<b>요금</b>
|
||||
• 월 ${spec.monthly_price_krw.toLocaleString()}원
|
||||
|
||||
🖥️ <b>OS를 선택해주세요:</b>
|
||||
• "우분투" 또는 "ubuntu 22"
|
||||
• "우분투 24" 또는 "ubuntu 24"
|
||||
• "데비안" 또는 "debian"
|
||||
• "센토스" 또는 "centos"
|
||||
|
||||
💡 "뒤로"로 추천 목록으로, "다시"로 새 추천, "취소"로 중단할 수 있습니다.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: spec_confirm - OS 선택
|
||||
if (step === 'spec_confirm') {
|
||||
// "뒤로" 패턴 - 추천 목록으로 돌아가기
|
||||
if (/^(뒤로|back)$/.test(lowerText)) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, '🔄 추천 목록을 다시 불러오는 중...');
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
tech_stack: ['nginx'],
|
||||
expected_users: 100,
|
||||
use_case: 'general purpose server',
|
||||
lang: 'ko'
|
||||
};
|
||||
|
||||
const response = env.SERVER_RECOMMEND
|
||||
? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
: await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
||||
|
||||
const result = await response.json() as {
|
||||
recommendations?: Array<{
|
||||
server: {
|
||||
instance_id: string;
|
||||
region_code: string;
|
||||
provider_name: string;
|
||||
vcpu: number;
|
||||
memory_mb: number;
|
||||
storage_gb: number;
|
||||
monthly_price: number;
|
||||
};
|
||||
score: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!result.recommendations || result.recommendations.length === 0) {
|
||||
throw new Error('No recommendations');
|
||||
}
|
||||
|
||||
const topRecs = result.recommendations.slice(0, 5);
|
||||
|
||||
// 세션 업데이트 (step: recommend, recommendations 다시 저장)
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'recommend',
|
||||
plan: undefined,
|
||||
region: undefined,
|
||||
provider: undefined,
|
||||
priceKrw: undefined,
|
||||
recommendations: topRecs.map(rec => ({
|
||||
plan: rec.server.instance_id,
|
||||
region: rec.server.region_code,
|
||||
provider: rec.server.provider_name.toLowerCase()
|
||||
}))
|
||||
});
|
||||
|
||||
let responseText = `🎯 <b>서버 추천</b>\n\n`;
|
||||
topRecs.forEach((rec, index) => {
|
||||
const server = rec.server;
|
||||
const ramGB = (server.memory_mb / 1024).toFixed(1);
|
||||
const priceKrw = Math.round(server.monthly_price);
|
||||
const regionDisplay = getRegionDisplay(server.region_code);
|
||||
responseText += `${NUM_EMOJIS[index]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
|
||||
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`;
|
||||
});
|
||||
responseText += `💬 <b>원하시는 번호를 입력해주세요</b>\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`;
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
||||
} catch (error) {
|
||||
console.error('[back] 추천 API 오류:', error);
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
'❌ 추천 목록을 불러오는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.'
|
||||
);
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// "다시" 패턴 - 새로 추천받기 (기존 세션 유지하면서 새 추천)
|
||||
if (/^(다시|다른)/.test(lowerText)) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, '🔄 새로운 추천을 받는 중...');
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
tech_stack: ['nginx'],
|
||||
expected_users: 100,
|
||||
use_case: 'general purpose server',
|
||||
lang: 'ko'
|
||||
};
|
||||
|
||||
const response = env.SERVER_RECOMMEND
|
||||
? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
: await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
||||
|
||||
const result = await response.json() as {
|
||||
recommendations?: Array<{
|
||||
server: {
|
||||
instance_id: string;
|
||||
region_code: string;
|
||||
provider_name: string;
|
||||
vcpu: number;
|
||||
memory_mb: number;
|
||||
storage_gb: number;
|
||||
monthly_price: number;
|
||||
};
|
||||
score: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!result.recommendations || result.recommendations.length === 0) {
|
||||
throw new Error('No recommendations');
|
||||
}
|
||||
|
||||
const topRecs = result.recommendations.slice(0, 5);
|
||||
|
||||
// 세션 업데이트 (새 추천으로 교체)
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'recommend',
|
||||
plan: undefined,
|
||||
region: undefined,
|
||||
provider: undefined,
|
||||
priceKrw: undefined,
|
||||
recommendations: topRecs.map(rec => ({
|
||||
plan: rec.server.instance_id,
|
||||
region: rec.server.region_code,
|
||||
provider: rec.server.provider_name.toLowerCase()
|
||||
}))
|
||||
});
|
||||
|
||||
let responseText = `🎯 <b>새로운 서버 추천</b>\n\n`;
|
||||
topRecs.forEach((rec, index) => {
|
||||
const server = rec.server;
|
||||
const ramGB = (server.memory_mb / 1024).toFixed(1);
|
||||
const priceKrw = Math.round(server.monthly_price);
|
||||
const regionDisplay = getRegionDisplay(server.region_code);
|
||||
responseText += `${NUM_EMOJIS[index]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
|
||||
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`;
|
||||
});
|
||||
responseText += `💬 <b>원하시는 번호를 입력해주세요</b>\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`;
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
||||
} catch (error) {
|
||||
console.error('[다시] 추천 API 오류:', error);
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
'❌ 새 추천을 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.'
|
||||
);
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// OS 패턴 매칭
|
||||
let osImage: string | null = null;
|
||||
if (/우분투\s*22|ubuntu\s*22|우분투$|ubuntu$/.test(lowerText)) {
|
||||
osImage = 'ubuntu-22.04';
|
||||
} else if (/우분투\s*24|ubuntu\s*24/.test(lowerText)) {
|
||||
osImage = 'ubuntu-24.04';
|
||||
} else if (/데비안|debian/.test(lowerText)) {
|
||||
osImage = 'debian-12';
|
||||
} else if (/센토스|centos/.test(lowerText)) {
|
||||
osImage = 'centos-stream-9';
|
||||
}
|
||||
|
||||
if (osImage) {
|
||||
const { plan, region, provider } = session.data;
|
||||
|
||||
// 사양 재조회
|
||||
const spec = await getServerSpec(
|
||||
env.CLOUD_DB,
|
||||
plan!,
|
||||
region!,
|
||||
provider!
|
||||
);
|
||||
|
||||
if (!spec) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, '❌ 사양 정보를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 잔액 확인
|
||||
const deposit = await env.DB.prepare(
|
||||
"SELECT balance FROM user_deposits WHERE user_id = ?"
|
||||
).bind(userId).first<{ balance: number }>();
|
||||
const balance = deposit?.balance || 0;
|
||||
|
||||
if (balance < spec.monthly_price_krw) {
|
||||
const shortage = spec.monthly_price_krw - balance;
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`❌ <b>잔액 부족</b>
|
||||
|
||||
• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
• 부족 금액: ${shortage.toLocaleString()}원
|
||||
|
||||
💳 <b>입금 계좌</b>
|
||||
하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||
|
||||
입금 후 다시 시도해주세요.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 주문 생성
|
||||
const label = `server-${Date.now()}`;
|
||||
const orderResult = await env.DB.prepare(`
|
||||
INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now'))
|
||||
`).bind(userId, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run();
|
||||
|
||||
const orderId = orderResult.meta?.last_row_id;
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'final_confirm',
|
||||
image: osImage,
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
|
||||
const ramGB = (spec.memory_mb / 1024).toFixed(1);
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`✅ <b>최종 확인</b>
|
||||
|
||||
• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD
|
||||
• OS: ${getOSDisplayName(osImage)}
|
||||
• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
|
||||
⚠️ <b>요금 안내</b>
|
||||
월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다.
|
||||
|
||||
🚀 서버를 생성하시려면 "<b>확인</b>" 또는 "<b>생성</b>"이라고 입력하세요.
|
||||
❌ 취소하시려면 "<b>취소</b>"라고 입력하세요.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: final_confirm - 최종 확인
|
||||
if (step === 'final_confirm') {
|
||||
// "뒤로" 패턴 - OS 선택으로
|
||||
if (/^(뒤로|back|os)/.test(lowerText)) {
|
||||
// pending 주문 삭제
|
||||
if (session.data.orderId) {
|
||||
await env.DB.prepare(
|
||||
"DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'"
|
||||
).bind(session.data.orderId, userId).run();
|
||||
}
|
||||
|
||||
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'spec_confirm',
|
||||
image: undefined,
|
||||
orderId: undefined
|
||||
});
|
||||
|
||||
// 선택된 사양 정보 표시
|
||||
const { priceKrw } = session.data;
|
||||
const priceInfo = priceKrw ? `${priceKrw.toLocaleString()}원/월` : '?';
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`🖥️ <b>OS를 다시 선택해주세요:</b>
|
||||
|
||||
현재 선택된 사양: ${priceInfo}
|
||||
|
||||
• "우분투" 또는 "ubuntu 22"
|
||||
• "우분투 24" 또는 "ubuntu 24"
|
||||
• "데비안" 또는 "debian"
|
||||
• "센토스" 또는 "centos"
|
||||
|
||||
💡 "뒤로"로 사양 선택으로 돌아가거나, "취소"로 중단할 수 있습니다.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 확인 패턴 - 서버 생성
|
||||
if (/^(확인|진행|생성|네|예|ok|yes|confirm)/.test(lowerText)) {
|
||||
const { orderId } = session.data;
|
||||
|
||||
if (!orderId) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, '❌ 주문 정보가 없습니다. 다시 시작해주세요.');
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, '⏳ 서버를 생성하고 있습니다... (1-3분 소요)');
|
||||
|
||||
// 서버 생성 실행은 webhook.ts에서 executeServerProvision 임포트
|
||||
const { executeServerProvision } = await import('../../server-provision');
|
||||
const result = await executeServerProvision(env, userId, telegramUserId, orderId);
|
||||
|
||||
// 세션 삭제
|
||||
await deleteSession(env.SESSION_KV, sessionId);
|
||||
|
||||
if (result.success) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`✅ <b>서버 생성 완료!</b>
|
||||
|
||||
• IP 주소: <code>${result.ip_address}</code>
|
||||
• Root 비밀번호: <code>${result.root_password}</code>
|
||||
|
||||
📌 <b>접속 방법</b>
|
||||
<code>ssh root@${result.ip_address}</code>
|
||||
|
||||
⚠️ <b>보안 권고</b>
|
||||
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
|
||||
2. SSH 키 인증 설정을 권장합니다.
|
||||
|
||||
🎉 서버가 성공적으로 생성되었습니다!`
|
||||
);
|
||||
} else {
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`❌ <b>서버 생성 실패</b>
|
||||
|
||||
${result.error}
|
||||
|
||||
다시 시도하시려면 "서버 추천해줘"라고 말씀해주세요.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 세션은 있지만 매칭되는 입력이 아닌 경우 - 힌트 제공
|
||||
// (AI 응답으로 넘어가도록 여기서 return 하지 않음)
|
||||
// 단, 명확한 서버 관련 질문이면 힌트 제공
|
||||
if (/서버|사양|os|운영체제/.test(lowerText) && !/추천|알려/.test(lowerText)) {
|
||||
let hint = '';
|
||||
if (step === 'recommend') {
|
||||
hint = '💡 추천 목록에서 번호를 선택해주세요. (예: "1번", "두번째")';
|
||||
} else if (step === 'spec_confirm') {
|
||||
hint = '💡 OS를 선택해주세요. (예: "우분투", "debian")';
|
||||
} else if (step === 'final_confirm') {
|
||||
hint = '💡 "확인"으로 서버를 생성하거나, "취소"로 주문을 취소할 수 있습니다.';
|
||||
}
|
||||
if (hint) {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, hint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// === 세션 기반 대화형 플로우 처리 끝 ===
|
||||
|
||||
// 5. 명령어 처리
|
||||
if (text.startsWith('/')) {
|
||||
const [command, ...argParts] = text.split(' ');
|
||||
const args = argParts.join(' ');
|
||||
const responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
||||
|
||||
// /start 명령어는 미니앱 버튼과 함께 전송
|
||||
if (command === '/start') {
|
||||
const hostingUrl = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [
|
||||
[{ text: '🌐 서비스 보기', web_app: { url: hostingUrl } }],
|
||||
[{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 일반 대화 처리 (ConversationService 위임)
|
||||
const result = await conversationService.processUserMessage(
|
||||
userId,
|
||||
chatIdStr,
|
||||
text,
|
||||
telegramUserId
|
||||
);
|
||||
|
||||
let finalResponse = result.responseText;
|
||||
if (result.isProfileUpdated) {
|
||||
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||
}
|
||||
|
||||
// 6. 응답 전송 (키보드 포함 여부 확인)
|
||||
if (result.keyboardData) {
|
||||
console.log('[Webhook] Keyboard data received:', result.keyboardData.type);
|
||||
if (result.keyboardData.type === 'domain_register') {
|
||||
const { domain, price } = result.keyboardData;
|
||||
const callbackData = `domain_reg:${domain}:${price}`;
|
||||
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [
|
||||
[
|
||||
{ text: '✅ 등록하기', callback_data: callbackData },
|
||||
{ text: '❌ 취소', callback_data: 'domain_cancel' }
|
||||
]
|
||||
]);
|
||||
} else if (result.keyboardData.type === 'server_order') {
|
||||
const { order_id } = result.keyboardData;
|
||||
const confirmData = `server_order:${order_id}`;
|
||||
const cancelData = `server_cancel:${order_id}`;
|
||||
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [
|
||||
[
|
||||
{ text: '✅ 생성하기', callback_data: confirmData },
|
||||
{ text: '❌ 취소', callback_data: cancelData }
|
||||
]
|
||||
]);
|
||||
} else if (result.keyboardData.type === 'server_recommend') {
|
||||
const { specs } = result.keyboardData;
|
||||
|
||||
// 세션 생성 (기존 세션 있으면 덮어씀) - 추천 목록 저장
|
||||
await createSession<ServerOrderSessionData>(
|
||||
env.SESSION_KV,
|
||||
userId,
|
||||
'server_order',
|
||||
{
|
||||
recommendations: specs.map(spec => ({
|
||||
plan: spec.plan,
|
||||
region: spec.region,
|
||||
provider: spec.provider
|
||||
}))
|
||||
},
|
||||
'recommend'
|
||||
);
|
||||
|
||||
// 대화형 안내 추가 (버튼 없이 메시지만)
|
||||
const guideText = `\n\n💬 <b>원하시는 번호를 입력해주세요</b>\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`;
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, finalResponse + guideText);
|
||||
} else {
|
||||
// TypeScript exhaustiveness check - should never reach here
|
||||
console.warn('[Webhook] Unknown keyboard type:', (result.keyboardData as { type: string }).type);
|
||||
await sendMessage(env.BOT_TOKEN, chatId, finalResponse);
|
||||
}
|
||||
} else {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, finalResponse);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[handleMessage] 처리 오류:', error);
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,230 +1,7 @@
|
||||
import { Env, TelegramUpdate } from '../types';
|
||||
import { validateWebhookRequest, checkRateLimit } from '../security';
|
||||
import { sendMessage, sendMessageWithKeyboard, answerCallbackQuery, editMessageText } from '../telegram';
|
||||
import { executeDomainRegister } from '../domain-register';
|
||||
import { handleCommand } from '../commands';
|
||||
import { UserService } from '../services/user-service';
|
||||
import { ConversationService } from '../services/conversation-service';
|
||||
import { ERROR_MESSAGES } from '../constants/messages';
|
||||
|
||||
/**
|
||||
* Safely parse integer with range validation
|
||||
* @param value - String to parse
|
||||
* @param min - Minimum allowed value (inclusive)
|
||||
* @param max - Maximum allowed value (inclusive)
|
||||
* @returns Parsed integer or null if invalid/out of range
|
||||
*/
|
||||
function parseIntSafe(value: string, min: number, max: number): number | null {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed) || parsed < min || parsed > max) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// 메시지 처리 핸들러
|
||||
async function handleMessage(
|
||||
env: Env,
|
||||
update: TelegramUpdate
|
||||
): Promise<void> {
|
||||
if (!update.message?.text) return;
|
||||
|
||||
const { message } = update;
|
||||
const chatId = message.chat.id;
|
||||
const chatIdStr = chatId.toString();
|
||||
const text = message.text!;
|
||||
const telegramUserId = message.from.id.toString();
|
||||
|
||||
// 1. Rate Limiting 체크
|
||||
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 서비스 인스턴스 초기화
|
||||
const userService = new UserService(env.DB);
|
||||
const conversationService = new ConversationService(env);
|
||||
|
||||
// 3. 사용자 조회/생성
|
||||
let userId: number;
|
||||
try {
|
||||
userId = await userService.getOrCreateUser(
|
||||
telegramUserId,
|
||||
message.from.first_name,
|
||||
message.from.username
|
||||
);
|
||||
} catch (dbError) {
|
||||
console.error('[handleMessage] 사용자 DB 오류:', dbError);
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
ERROR_MESSAGES.TEMPORARY_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. 명령어 처리
|
||||
if (text.startsWith('/')) {
|
||||
const [command, ...argParts] = text.split(' ');
|
||||
const args = argParts.join(' ');
|
||||
const responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
||||
|
||||
// /start 명령어는 미니앱 버튼과 함께 전송
|
||||
if (command === '/start') {
|
||||
const hostingUrl = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [
|
||||
[{ text: '🌐 서비스 보기', web_app: { url: hostingUrl } }],
|
||||
[{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 일반 대화 처리 (ConversationService 위임)
|
||||
const result = await conversationService.processUserMessage(
|
||||
userId,
|
||||
chatIdStr,
|
||||
text,
|
||||
telegramUserId
|
||||
);
|
||||
|
||||
let finalResponse = result.responseText;
|
||||
if (result.isProfileUpdated) {
|
||||
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||
}
|
||||
|
||||
// 6. 응답 전송 (키보드 포함 여부 확인)
|
||||
if (result.keyboardData && result.keyboardData.type === 'domain_register') {
|
||||
const { domain, price } = result.keyboardData;
|
||||
const callbackData = `domain_reg:${domain}:${price}`;
|
||||
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [
|
||||
[
|
||||
{ text: '✅ 등록하기', callback_data: callbackData },
|
||||
{ text: '❌ 취소', callback_data: 'domain_cancel' }
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, finalResponse);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[handleMessage] 처리 오류:', error);
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Callback Query 처리 (인라인 버튼 클릭)
|
||||
async function handleCallbackQuery(
|
||||
env: Env,
|
||||
callbackQuery: TelegramUpdate['callback_query']
|
||||
): Promise<void> {
|
||||
if (!callbackQuery) return;
|
||||
|
||||
const { id: queryId, from, message, data } = callbackQuery;
|
||||
if (!data || !message) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = message.chat.id;
|
||||
const messageId = message.message_id;
|
||||
const telegramUserId = from.id.toString();
|
||||
|
||||
const userService = new UserService(env.DB);
|
||||
const user = await userService.getUserByTelegramId(telegramUserId);
|
||||
|
||||
if (!user) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 도메인 등록 처리
|
||||
if (data.startsWith('domain_reg:')) {
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 3) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = parts[1];
|
||||
const price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW
|
||||
|
||||
if (price === null) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`⏳ <b>${domain}</b> 등록 처리 중...`
|
||||
);
|
||||
|
||||
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
|
||||
|
||||
if (result.success) {
|
||||
const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : '';
|
||||
const nsInfo = result.nameservers && result.nameservers.length > 0
|
||||
? `\n\n🌐 <b>현재 네임서버:</b>\n${result.nameservers.map(ns => `• <code>${ns}</code>`).join('\n')}`
|
||||
: '';
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`✅ <b>도메인 등록 완료!</b>
|
||||
|
||||
• 도메인: <code>${result.domain}</code>
|
||||
• 결제 금액: ${result.price?.toLocaleString()}원
|
||||
• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo}
|
||||
|
||||
🎉 축하합니다! 도메인이 성공적으로 등록되었습니다.
|
||||
네임서버 변경이 필요하면 말씀해주세요.`
|
||||
);
|
||||
} else {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>등록 실패</b>
|
||||
|
||||
${result.error}
|
||||
|
||||
다시 시도하시려면 도메인 등록을 요청해주세요.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 도메인 등록 취소
|
||||
if (data === 'domain_cancel') {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
'❌ 도메인 등록이 취소되었습니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
||||
}
|
||||
import type { Env } from '../types';
|
||||
import { validateWebhookRequest } from '../security';
|
||||
import { handleCallbackQuery } from './handlers/callback-handler';
|
||||
import { handleMessage } from './handlers/message-handler';
|
||||
|
||||
/**
|
||||
* Telegram Webhook 요청 처리
|
||||
|
||||
Reference in New Issue
Block a user