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:
kappa
2026-01-24 21:01:38 +09:00
parent dab279c765
commit 6563ee0650
14 changed files with 4019 additions and 241 deletions

View File

@@ -0,0 +1,52 @@
-- Migration 003: Add server management tables
-- Purpose: Cloud server order tracking
-- Date: 2026-01-23
-- Reference: CLAUDE.md "Server Management System"
--
-- Background:
-- Telegram 봇을 통해 클라우드 서버 주문 내역을 기록하고 관리합니다.
-- 예치금 시스템과 통합하여 자동 결제를 지원합니다.
-- 서버 사양(cloud_providers, instance_specs)은 별도 외부 시스템에서 관리합니다.
-- Step 1: Create server_orders table (order lifecycle tracking)
CREATE TABLE IF NOT EXISTS server_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
spec_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'active', 'failed', 'cancelled', 'terminated')),
region TEXT NOT NULL,
provider_instance_id TEXT,
ip_address TEXT,
root_password TEXT,
price_paid INTEGER NOT NULL,
error_message TEXT,
provisioned_at DATETIME,
terminated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Step 2: Create user_servers table (ownership mapping)
CREATE TABLE IF NOT EXISTS user_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id INTEGER UNIQUE NOT NULL,
provider_id INTEGER NOT NULL,
label TEXT,
verified INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (order_id) REFERENCES server_orders(id)
);
-- Step 3: Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_server_orders_user ON server_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_server_orders_status ON server_orders(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id);
CREATE INDEX IF NOT EXISTS idx_user_servers_provider ON user_servers(provider_id);
-- Verification Queries (주석으로 제공)
-- SELECT * FROM server_orders WHERE user_id = 1 ORDER BY created_at DESC;
-- SELECT * FROM user_servers WHERE user_id = 1;

68
src/constants/server.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Server provisioning constants
* Centralized OS images, region mappings, and display helpers
*/
/**
* OS image mappings
* Maps OS image IDs to user-friendly display names
*/
export const OS_IMAGES = {
'ubuntu-22.04': 'Ubuntu 22.04 LTS',
'ubuntu-24.04': 'Ubuntu 24.04 LTS',
'debian-12': 'Debian 12',
'centos-stream-9': 'CentOS Stream 9'
} as const;
export type OSImageKey = keyof typeof OS_IMAGES;
/**
* Region code to flag emoji and localized name mapping
* Covers both Linode and Vultr region codes
*/
export const REGION_FLAGS: Record<string, { flag: string; name: string }> = {
// Linode
'ap-northeast': { flag: '🇯🇵', name: '오사카' },
'ap-south': { flag: '🇸🇬', name: '싱가포르' },
'ap-southeast': { flag: '🇦🇺', name: '시드니' },
'ap-west': { flag: '🇮🇳', name: '뭄바이' },
'us-west': { flag: '🇺🇸', name: 'LA' },
'us-central': { flag: '🇺🇸', name: '댈러스' },
'us-east': { flag: '🇺🇸', name: '뉴저지' },
'eu-west': { flag: '🇬🇧', name: '런던' },
'eu-central': { flag: '🇩🇪', name: '프랑크푸르트' },
// Vultr
'nrt': { flag: '🇯🇵', name: '도쿄' },
'icn': { flag: '🇰🇷', name: '서울' },
'sgp': { flag: '🇸🇬', name: '싱가포르' },
'syd': { flag: '🇦🇺', name: '시드니' },
'lax': { flag: '🇺🇸', name: 'LA' },
'ord': { flag: '🇺🇸', name: '시카고' },
'ewr': { flag: '🇺🇸', name: '뉴저지' },
'lhr': { flag: '🇬🇧', name: '런던' },
'fra': { flag: '🇩🇪', name: '프랑크푸르트' },
};
/**
* Number emojis for list display (1-5)
*/
export const NUM_EMOJIS = ['1⃣', '2⃣', '3⃣', '4⃣', '5⃣'] as const;
/**
* Get formatted region display with flag and name
* @param regionCode - Region code (e.g., 'ap-northeast', 'nrt')
* @returns Formatted string like "🇯🇵 오사카" or original code if not found
*/
export function getRegionDisplay(regionCode: string): string {
const info = REGION_FLAGS[regionCode];
return info ? `${info.flag} ${info.name}` : regionCode;
}
/**
* Get user-friendly OS display name
* @param osImage - OS image ID (e.g., 'ubuntu-22.04')
* @returns Display name like "Ubuntu 22.04 LTS" or original ID if not found
*/
export function getOSDisplayName(osImage: string): string {
return OS_IMAGES[osImage as OSImageKey] || osImage;
}

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

View 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,
'⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
);
}
}

View File

@@ -1,230 +1,7 @@
import { Env, TelegramUpdate } from '../types'; import type { Env } from '../types';
import { validateWebhookRequest, checkRateLimit } from '../security'; import { validateWebhookRequest } from '../security';
import { sendMessage, sendMessageWithKeyboard, answerCallbackQuery, editMessageText } from '../telegram'; import { handleCallbackQuery } from './handlers/callback-handler';
import { executeDomainRegister } from '../domain-register'; import { handleMessage } from './handlers/message-handler';
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);
}
/** /**
* Telegram Webhook 요청 처리 * Telegram Webhook 요청 처리

487
src/server-provision.ts Normal file
View File

@@ -0,0 +1,487 @@
/**
* Server Provisioning Orchestrator
*
* Purpose: Execute actual server creation after user confirmation
*
* Flow:
* 1. Fetch order from DB (server_orders)
* 2. Fetch spec from CLOUD_DB (pricing + instance_types + providers + regions)
* 3. Validate status (only 'pending' orders)
* 4. Re-check balance (Optimistic Locking)
* 5. Update status: provisioning
* 6. Call Cloud API (Linode or Vultr)
* 7. Deduct balance + record transaction (Optimistic Locking, db.batch)
* 8. Update order (status='active', IP addresses, provider_instance_id)
* 9. Add to user_servers table
* 10. Return result
*
* On failure:
* - Set status='failed', error_message
* - Do NOT deduct balance (balance deduction happens only after successful provisioning)
*
* DB Architecture:
* - env.DB (telegram-conversations): server_orders, user_servers, user_deposits
* - env.CLOUD_DB (cloud-instances-db): pricing, instance_types, providers, regions
*/
import { createLogger } from './utils/logger';
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
import { createInstance as createLinodeInstance } from './services/linode-api';
import { createInstance as createVultrInstance } from './services/vultr-api';
import { notifyAdmin } from './services/notification';
import { sendMessage } from './telegram';
import type {
Env,
LinodeInstance,
VultrInstance,
} from './types';
const logger = createLogger('server-provision');
export interface ProvisionResult {
success: boolean;
order_id?: number;
instance_id?: string;
ip_address?: string;
root_password?: string; // Plain text (shown only once)
region?: string; // Region name (한글)
plan_label?: string;
error?: string;
}
// Order row from DB
interface OrderRow {
id: number;
user_id: number;
spec_id: number; // pricing.id from CLOUD_DB
status: string;
region: string; // region_code
price_paid: number;
}
// Spec info from CLOUD_DB
interface SpecInfo {
instance_id: string; // provider's plan ID (e.g., vc2-1c-1gb)
instance_name: string; // display name
vcpu: number;
memory_mb: number;
storage_gb: number;
region_code: string;
region_name: string;
provider_id: number;
provider_name: string;
}
/**
* Execute server provisioning after user confirmation
*
* @param env - Environment variables (API keys, DB)
* @param userId - User ID (for ownership check)
* @param telegramUserId - Telegram User ID (for logging)
* @param orderId - Server order ID
* @returns ProvisionResult
*/
export async function executeServerProvision(
env: Env,
userId: number,
telegramUserId: string,
orderId: number
): Promise<ProvisionResult> {
try {
// 1. Fetch order from DB (server_orders only)
const orderRow = await env.DB.prepare(
`SELECT id, user_id, spec_id, status, region, price_paid
FROM server_orders
WHERE id = ?`
).bind(orderId).first<OrderRow>();
if (!orderRow) {
logger.warn('Order not found', { orderId });
return { success: false, error: '주문을 찾을 수 없습니다.' };
}
// 2. Validate ownership
if (orderRow.user_id !== userId) {
logger.warn('Order ownership mismatch', {
orderId,
userId,
orderUserId: orderRow.user_id,
});
return { success: false, error: '본인의 주문만 처리할 수 있습니다.' };
}
// 3. Validate status (only 'pending' orders can be provisioned)
if (orderRow.status !== 'pending') {
logger.warn('Order status not pending', {
orderId,
status: orderRow.status,
});
return {
success: false,
error: `이미 처리된 주문입니다. (상태: ${orderRow.status})`,
};
}
// 4. Fetch spec info from CLOUD_DB
if (!env.CLOUD_DB) {
logger.error('CLOUD_DB not available', undefined, { orderId });
return { success: false, error: '서버 사양 데이터베이스에 접근할 수 없습니다.' };
}
const specInfo = await env.CLOUD_DB.prepare(
`SELECT
it.instance_id,
it.instance_name,
it.vcpu,
it.memory_mb,
it.storage_gb,
r.region_code,
r.region_name,
prov.id as provider_id,
prov.name as provider_name
FROM pricing p
JOIN instance_types it ON p.instance_type_id = it.id
JOIN regions r ON p.region_id = r.id
JOIN providers prov ON it.provider_id = prov.id
WHERE p.id = ?`
).bind(orderRow.spec_id).first<SpecInfo>();
if (!specInfo) {
logger.warn('Spec not found in CLOUD_DB', { specId: orderRow.spec_id });
return { success: false, error: '서버 사양을 찾을 수 없습니다.' };
}
// 5. Re-check balance (security measure)
const balanceRow = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number }>();
const currentBalance = balanceRow?.balance || 0;
if (currentBalance < orderRow.price_paid) {
logger.warn('Insufficient balance on provision', {
orderId,
userId,
currentBalance,
requiredAmount: orderRow.price_paid,
});
return {
success: false,
error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${orderRow.price_paid.toLocaleString()}원)`,
};
}
// 6. Update status: provisioning
const statusUpdate = await env.DB.prepare(
"UPDATE server_orders SET status = 'provisioning', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'"
).bind(orderId).run();
if (!statusUpdate.success || statusUpdate.meta.changes === 0) {
logger.error('Failed to update order status to provisioning', undefined, {
orderId,
updateResult: statusUpdate,
});
return { success: false, error: '주문 상태 업데이트에 실패했습니다.' };
}
logger.info('Order status updated to provisioning', {
orderId,
provider: specInfo.provider_name,
plan: specInfo.instance_name,
region: orderRow.region,
});
// 7. Generate secure root password (20 chars)
const rootPassword = generateSecurePassword();
// 8. Call Cloud API (Linode or Vultr)
let instanceId: string | number;
let ipAddress: string;
// Generate label from order ID and instance name
const instanceLabel = `order-${orderId}`;
try {
if (specInfo.provider_name === 'linode') {
// Linode API
const linodeInstance: LinodeInstance = await createLinodeInstance(
{
type: specInfo.instance_id,
region: orderRow.region,
image: 'linode/ubuntu22.04',
root_pass: rootPassword,
label: instanceLabel,
},
env
);
instanceId = linodeInstance.id;
ipAddress = linodeInstance.ipv4[0] || '';
logger.info('Linode instance created', {
orderId,
instanceId,
ipAddress,
label: linodeInstance.label,
});
} else if (specInfo.provider_name === 'vultr') {
// Vultr API
const vultrInstance: VultrInstance = await createVultrInstance(
{
plan: specInfo.instance_id,
region: orderRow.region,
os_id: 2136, // Ubuntu 24.04 LTS
label: instanceLabel,
hostname: instanceLabel,
},
env
);
instanceId = vultrInstance.id;
ipAddress = vultrInstance.main_ip || '';
logger.info('Vultr instance created', {
orderId,
instanceId,
ipAddress,
label: vultrInstance.label,
});
} else {
throw new Error(`Unsupported provider: ${specInfo.provider_name}`);
}
} catch (apiError) {
// Cloud API call failed
logger.error('Cloud API call failed', apiError as Error, {
orderId,
provider: specInfo.provider_name,
region: orderRow.region,
});
// Set status to 'failed'
await env.DB.prepare(
"UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
).bind(
`Cloud API 호출 실패: ${String(apiError)}`,
orderId
).run();
return {
success: false,
error: `서버 생성에 실패했습니다. 관리자에게 문의하세요. (주문번호: #${orderId})`,
};
}
// 9. Deduct balance + record transaction (Optimistic Locking)
try {
await executeWithOptimisticLock(env.DB, async (attempt) => {
// 9-1. Get current version
const current = await env.DB.prepare(
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number; version: number }>();
if (!current) {
throw new Error('User deposit account not found');
}
// Double-check balance again (within lock)
if (current.balance < orderRow.price_paid) {
throw new Error('Insufficient balance during lock');
}
// 9-2. Update balance with version check
const balanceUpdate = await env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
).bind(orderRow.price_paid, userId, current.version).run();
if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
throw new OptimisticLockError('Version mismatch on balance update');
}
// 9-3. Record withdrawal transaction
const txInsert = await env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(
userId,
orderRow.price_paid,
`서버 프로비저닝: ${instanceLabel} (${specInfo.instance_name})`
).run();
if (!txInsert.success) {
logger.error('Failed to insert withdrawal transaction', undefined, {
orderId,
userId,
amount: orderRow.price_paid,
attempt,
});
throw new Error('Transaction insert failed');
}
logger.info('Balance deducted successfully', {
orderId,
userId,
amount: orderRow.price_paid,
newBalance: current.balance - orderRow.price_paid,
attempt,
});
return true;
});
} catch (lockError) {
// Balance deduction failed - instance already created, mark as failed for manual cleanup
logger.error('Balance deduction failed after instance creation', lockError as Error, {
orderId,
instanceId,
provider: specInfo.provider_name,
});
// 1. 관리자에게 즉시 알림 전송 (비용 누수 방지)
try {
await notifyAdmin(
'api_error',
{
service: 'server-provision',
error: '인스턴스 생성 완료 후 잔액 차감 실패',
context: `주문번호: ${orderId}\n제공자: ${specInfo.provider_name}\n인스턴스 ID: ${instanceId}\n금액: ${orderRow.price_paid.toLocaleString()}\n에러: ${String(lockError)}`,
},
{
telegram: {
sendMessage: (chatId: number, text: string) =>
sendMessage(env.BOT_TOKEN, chatId, text)
},
adminId: env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID || '',
env,
}
);
} catch (notifyError) {
// 알림 실패는 로그만 기록 (메인 로직에 영향 없음)
logger.error('관리자 알림 전송 실패', notifyError as Error, { orderId });
}
// 2. 기존 에러 처리 로직 유지
await env.DB.prepare(
"UPDATE server_orders SET status = 'failed', error_message = ?, provider_instance_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
).bind(
`결제 처리 실패 (인스턴스 생성 완료, 수동 정리 필요): ${String(lockError)}`,
String(instanceId),
orderId
).run();
return {
success: false,
error: '결제 처리 중 오류가 발생했습니다. 관리자에게 문의하세요.',
};
}
// 10. Update order (status='active', IP address, provider_instance_id)
const orderUpdate = await env.DB.prepare(
`UPDATE server_orders
SET status = 'active',
provider_instance_id = ?,
ip_address = ?,
root_password = ?,
provisioned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
).bind(
String(instanceId),
ipAddress,
rootPassword,
orderId
).run();
if (!orderUpdate.success) {
logger.error('Failed to update order after provisioning', undefined, {
orderId,
instanceId,
});
// Don't fail here - instance is already created and paid
}
// 11. Add to user_servers table (only existing columns)
const serverInsert = await env.DB.prepare(
`INSERT INTO user_servers (user_id, order_id, provider_id, label, verified)
VALUES (?, ?, ?, ?, 1)`
).bind(
userId,
orderId,
specInfo.provider_id,
instanceLabel
).run();
if (!serverInsert.success) {
logger.error('Failed to insert user_servers record', undefined, {
orderId,
instanceId,
});
// Don't fail here - instance is already created and paid
}
logger.info('Server provisioning completed successfully', {
orderId,
instanceId,
ipAddress,
provider: specInfo.provider_name,
plan: specInfo.instance_name,
telegramUserId,
});
// 12. Return success
return {
success: true,
order_id: orderId,
instance_id: String(instanceId),
ip_address: ipAddress,
root_password: rootPassword,
region: specInfo.region_name,
plan_label: specInfo.instance_name,
};
} catch (error) {
logger.error('Server provisioning failed', error as Error, {
orderId,
telegramUserId,
});
// Try to mark order as failed (best effort)
try {
await env.DB.prepare(
"UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
).bind(
String(error),
orderId
).run();
} catch (updateError) {
logger.error('Failed to update order status to failed', updateError as Error, {
orderId,
});
}
return {
success: false,
error: `서버 프로비저닝 중 오류가 발생했습니다: ${String(error)}`,
};
}
}
/**
* Generate a secure random password (20 characters)
*
* Character set: uppercase, lowercase, digits, safe symbols
* Avoids ambiguous characters: I, l, 1, O, 0
*
* @returns Random password string
*/
function generateSecurePassword(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%';
let password = '';
const randomValues = new Uint8Array(20);
crypto.getRandomValues(randomValues);
for (let i = 0; i < 20; i++) {
password += chars[randomValues[i] % chars.length];
}
return password;
}

View File

@@ -0,0 +1,43 @@
export interface ServerSpec {
pricing_id: number;
vcpu: number;
memory_mb: number;
storage_gb: number;
transfer_tb: number;
network_speed_gbps: number | null;
monthly_price_krw: number;
region_name: string;
}
/**
* 서버 사양 조회 (CLOUD_DB)
*
* @param db - CLOUD_DB 바인딩
* @param plan - 인스턴스 ID (예: 'g6-nanode-1', 'vc2-1c-1gb')
* @param region - 리전 코드 (예: 'ap-northeast', 'nrt')
* @param provider - 제공자명 (소문자, 예: 'linode', 'vultr')
* @returns ServerSpec | null
*/
export async function getServerSpec(
db: D1Database,
plan: string,
region: string,
provider: string
): Promise<ServerSpec | null> {
return await db.prepare(`
SELECT
p.id as pricing_id,
it.vcpu,
it.memory_mb,
it.storage_gb,
it.transfer_tb,
it.network_speed_gbps,
p.monthly_price_krw,
r.region_name
FROM pricing p
JOIN instance_types it ON p.instance_type_id = it.id
JOIN regions r ON p.region_id = r.id AND r.provider_id = it.provider_id
JOIN providers pr ON it.provider_id = pr.id
WHERE it.instance_id = ? AND r.region_code = ? AND pr.name = ?
`).bind(plan, region, provider).first<ServerSpec>();
}

234
src/services/linode-api.ts Normal file
View File

@@ -0,0 +1,234 @@
/**
* Linode API Client
*
* REST API 클라이언트 for Linode cloud provider
* - Instance management (create, get)
* - Region listing
* - Automatic retry with exponential backoff
*/
import type { Env, LinodeInstance, LinodeCreateRequest } from '../types';
import { createLogger } from '../utils/logger';
import { retryWithBackoff } from '../utils/retry';
const logger = createLogger('linode-api');
/**
* Linode API Base URLs
*/
const DEFAULT_API_BASE = 'https://api.linode.com/v4';
/**
* Linode Region
*/
export interface LinodeRegion {
id: string;
label: string;
country: string;
}
/**
* Linode API Error
*/
export class LinodeAPIError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly response?: any
) {
super(message);
this.name = 'LinodeAPIError';
}
}
/**
* Create a Linode instance
*
* @param params - Instance creation parameters
* @param env - Environment variables (API key, base URL)
* @returns Created instance information
* @throws LinodeAPIError if API call fails
*/
export async function createInstance(
params: LinodeCreateRequest,
env: Env
): Promise<LinodeInstance> {
const apiKey = env.LINODE_API_KEY;
if (!apiKey) {
throw new Error('LINODE_API_KEY is not configured');
}
const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE;
const url = `${apiBase}/linode/instances`;
logger.info('Creating Linode instance', {
type: params.type,
region: params.region,
label: params.label,
});
return retryWithBackoff(
async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
logger.error('Linode API create instance failed', undefined, {
status: response.status,
statusText: response.statusText,
error: errorBody,
});
throw new LinodeAPIError(
`Linode API error: ${response.status} ${response.statusText}`,
response.status,
errorBody
);
}
const data = await response.json() as LinodeInstance;
logger.info('Linode instance created successfully', {
id: data.id,
label: data.label,
ipv4: data.ipv4,
});
return data;
},
{
maxRetries: 3,
initialDelayMs: 1000,
serviceName: 'linode-api',
}
);
}
/**
* Get a Linode instance by ID
*
* @param instanceId - Linode instance ID
* @param env - Environment variables (API key, base URL)
* @returns Instance information
* @throws LinodeAPIError if API call fails
*/
export async function getInstance(
instanceId: number,
env: Env
): Promise<LinodeInstance> {
const apiKey = env.LINODE_API_KEY;
if (!apiKey) {
throw new Error('LINODE_API_KEY is not configured');
}
const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE;
const url = `${apiBase}/linode/instances/${instanceId}`;
logger.info('Getting Linode instance', { instanceId });
return retryWithBackoff(
async () => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
logger.error('Linode API get instance failed', undefined, {
status: response.status,
statusText: response.statusText,
instanceId,
error: errorBody,
});
throw new LinodeAPIError(
`Linode API error: ${response.status} ${response.statusText}`,
response.status,
errorBody
);
}
const data = await response.json() as LinodeInstance;
logger.info('Linode instance retrieved successfully', {
id: data.id,
label: data.label,
status: data.status,
});
return data;
},
{
maxRetries: 3,
initialDelayMs: 1000,
serviceName: 'linode-api',
}
);
}
/**
* Get list of available Linode regions
*
* @param env - Environment variables (API key, base URL)
* @returns Array of available regions
* @throws LinodeAPIError if API call fails
*/
export async function getRegions(env: Env): Promise<LinodeRegion[]> {
const apiKey = env.LINODE_API_KEY;
if (!apiKey) {
throw new Error('LINODE_API_KEY is not configured');
}
const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE;
const url = `${apiBase}/regions`;
logger.info('Getting Linode regions');
return retryWithBackoff(
async () => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
logger.error('Linode API get regions failed', undefined, {
status: response.status,
statusText: response.statusText,
error: errorBody,
});
throw new LinodeAPIError(
`Linode API error: ${response.status} ${response.statusText}`,
response.status,
errorBody
);
}
const responseData = await response.json() as { data: LinodeRegion[] };
const regions = responseData.data;
logger.info('Linode regions retrieved successfully', {
count: regions.length,
});
return regions;
},
{
maxRetries: 3,
initialDelayMs: 1000,
serviceName: 'linode-api',
}
);
}

View File

@@ -0,0 +1,65 @@
import type { Env } from '../types';
export interface ServerRecommendation {
server: {
instance_id: string;
provider_name: string;
region_code: string;
vcpu: number;
memory_mb: number;
storage_gb: number;
monthly_price: number;
};
score: number;
}
export interface RecommendOptions {
tech_stack?: string[];
expected_users?: number;
use_case?: string;
lang?: string;
}
/**
* 서버 추천 API 호출 헬퍼 함수
*
* @param env - Cloudflare Workers 환경 변수
* @param options - 추천 옵션 (기본값: nginx, 100명, general purpose)
* @returns 서버 추천 목록
* @throws API 호출 실패 시 에러
*/
export async function fetchServerRecommendations(
env: Env,
options?: RecommendOptions
): Promise<ServerRecommendation[]> {
const requestBody = {
tech_stack: options?.tech_stack || ['nginx'],
expected_users: options?.expected_users || 100,
use_case: options?.use_case || 'general purpose server',
lang: options?.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(`Recommendation API error: ${response.status}`);
}
const data = await response.json() as { recommendations?: ServerRecommendation[] };
if (!data.recommendations || data.recommendations.length === 0) {
throw new Error('No recommendations available');
}
return data.recommendations;
}

240
src/services/vultr-api.ts Normal file
View File

@@ -0,0 +1,240 @@
/**
* Vultr API Client
*
* REST API 클라이언트 for Vultr cloud provider
* - Instance management (create, get)
* - Region listing
* - Automatic retry with exponential backoff
*/
import type { Env, VultrInstance, VultrCreateRequest } from '../types';
import { createLogger } from '../utils/logger';
import { retryWithBackoff } from '../utils/retry';
const logger = createLogger('vultr-api');
/**
* Vultr API Base URLs
*/
const DEFAULT_API_BASE = 'https://api.vultr.com/v2';
/**
* Vultr Region
*/
export interface VultrRegion {
id: string;
city: string;
country: string;
continent: string;
options: string[];
}
/**
* Vultr API Error
*/
export class VultrAPIError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly response?: any
) {
super(message);
this.name = 'VultrAPIError';
}
}
/**
* Create a Vultr instance
*
* @param params - Instance creation parameters
* @param env - Environment variables (API key, base URL)
* @returns Created instance information
* @throws VultrAPIError if API call fails
*/
export async function createInstance(
params: VultrCreateRequest,
env: Env
): Promise<VultrInstance> {
const apiKey = env.VULTR_API_KEY;
if (!apiKey) {
throw new Error('VULTR_API_KEY is not configured');
}
const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE;
const url = `${apiBase}/instances`;
logger.info('Creating Vultr instance', {
plan: params.plan,
region: params.region,
label: params.label,
});
return retryWithBackoff(
async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
logger.error('Vultr API create instance failed', undefined, {
status: response.status,
statusText: response.statusText,
error: errorBody,
});
throw new VultrAPIError(
`Vultr API error: ${response.status} ${response.statusText}`,
response.status,
errorBody
);
}
const responseData = await response.json() as { instance: VultrInstance };
const instance = responseData.instance;
logger.info('Vultr instance created successfully', {
id: instance.id,
label: instance.label,
main_ip: instance.main_ip,
});
return instance;
},
{
maxRetries: 3,
initialDelayMs: 1000,
serviceName: 'vultr-api',
}
);
}
/**
* Get a Vultr instance by ID
*
* @param instanceId - Vultr instance ID
* @param env - Environment variables (API key, base URL)
* @returns Instance information
* @throws VultrAPIError if API call fails
*/
export async function getInstance(
instanceId: string,
env: Env
): Promise<VultrInstance> {
const apiKey = env.VULTR_API_KEY;
if (!apiKey) {
throw new Error('VULTR_API_KEY is not configured');
}
const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE;
const url = `${apiBase}/instances/${instanceId}`;
logger.info('Getting Vultr instance', { instanceId });
return retryWithBackoff(
async () => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
logger.error('Vultr API get instance failed', undefined, {
status: response.status,
statusText: response.statusText,
instanceId,
error: errorBody,
});
throw new VultrAPIError(
`Vultr API error: ${response.status} ${response.statusText}`,
response.status,
errorBody
);
}
const responseData = await response.json() as { instance: VultrInstance };
const instance = responseData.instance;
logger.info('Vultr instance retrieved successfully', {
id: instance.id,
label: instance.label,
status: instance.status,
});
return instance;
},
{
maxRetries: 3,
initialDelayMs: 1000,
serviceName: 'vultr-api',
}
);
}
/**
* Get list of available Vultr regions
*
* @param env - Environment variables (API key, base URL)
* @returns Array of available regions
* @throws VultrAPIError if API call fails
*/
export async function getRegions(env: Env): Promise<VultrRegion[]> {
const apiKey = env.VULTR_API_KEY;
if (!apiKey) {
throw new Error('VULTR_API_KEY is not configured');
}
const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE;
const url = `${apiBase}/regions`;
logger.info('Getting Vultr regions');
return retryWithBackoff(
async () => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
logger.error('Vultr API get regions failed', undefined, {
status: response.status,
statusText: response.statusText,
error: errorBody,
});
throw new VultrAPIError(
`Vultr API error: ${response.status} ${response.statusText}`,
response.status,
errorBody
);
}
const responseData = await response.json() as { regions: VultrRegion[] };
const regions = responseData.regions;
logger.info('Vultr regions retrieved successfully', {
count: regions.length,
});
return regions;
},
{
maxRetries: 3,
initialDelayMs: 1000,
serviceName: 'vultr-api',
}
);
}

1015
src/tools/server-tool.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
export interface Env { export interface Env {
DB: D1Database; DB: D1Database;
CLOUD_DB: D1Database;
AI: Ai; AI: Ai;
BOT_TOKEN: string; BOT_TOKEN: string;
WEBHOOK_SECRET: string; WEBHOOK_SECRET: string;
@@ -21,11 +22,20 @@ export interface Env {
BRAVE_API_BASE?: string; BRAVE_API_BASE?: string;
WTTR_IN_URL?: string; WTTR_IN_URL?: string;
HOSTING_SITE_URL?: string; HOSTING_SITE_URL?: string;
LINODE_API_KEY?: string;
VULTR_API_KEY?: string;
LINODE_API_BASE?: string;
VULTR_API_BASE?: string;
SERVER_ADMIN_ID?: string;
SERVER_RECOMMEND_API_URL?: string;
RATE_LIMIT_KV: KVNamespace; RATE_LIMIT_KV: KVNamespace;
SESSION_KV: KVNamespace;
// Service Binding: Worker-to-Worker 호출
SERVER_RECOMMEND?: Fetcher;
} }
export interface IntentAnalysis { export interface IntentAnalysis {
action: 'chat' | 'n8n'; action: "chat" | "n8n";
type?: string; type?: string;
confidence: number; confidence: number;
} }
@@ -72,7 +82,7 @@ export interface TelegramChat {
export interface BufferedMessage { export interface BufferedMessage {
id: number; id: number;
role: 'user' | 'bot'; role: "user" | "bot";
message: string; message: string;
created_at: string; created_at: string;
} }
@@ -92,6 +102,89 @@ export interface ConversationContext {
totalMessages: number; totalMessages: number;
} }
// Server Management - DB Entities
export interface CloudProvider {
id: number;
name: string;
display_name: string;
api_base_url: string;
enabled: number;
}
export interface InstanceSpec {
id: number;
instance_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number;
transfer_tb: number;
monthly_price_krw: number;
region_name: string;
provider_name: string; // internal use only (don't show to user)
}
export interface ServerOrder {
id: number;
user_id: number;
spec_id: number;
status: string;
provider_instance_id: string | null;
label: string;
region: string;
image: string;
ip_address: string | null;
ipv6_address: string | null;
root_password: string | null;
price_paid: number;
billing_type: string;
error_message: string | null;
provisioned_at: string | null;
terminated_at: string | null;
created_at: string;
}
export interface UserServer {
id: number;
user_id: number;
order_id: number;
provider_id: number;
provider_instance_id: string;
label: string;
status: string;
ip_address: string;
verified: number;
created_at: string;
updated_at: string;
}
// App Requirements Calculator Types
export interface AppRequirementTier {
minUsers: number;
maxUsers?: number;
vcpus: number;
memoryMb: number;
storageGb: number;
tierName?: string;
}
export interface AppRequirement {
appType: 'game' | 'web' | 'database' | 'container' | 'dev';
appName: string;
appNameKo?: string;
keywords: string[];
baseVcpus: number;
baseMemoryMb: number;
baseStorageGb: number;
memoryPerUserMb: number;
vcpuPerUsers: number;
maxUsersPerInstance?: number;
scalingNote?: string;
tiers?: AppRequirementTier[];
sourceUrl?: string;
confidenceLevel: 'low' | 'medium' | 'high' | 'official';
fetchedAt?: string;
}
// Cloudflare Email Workers 타입 // Cloudflare Email Workers 타입
export interface EmailMessage { export interface EmailMessage {
from: string; from: string;
@@ -150,14 +243,31 @@ export interface NamecheapDomainListItem {
// Function Calling 인자 타입 // Function Calling 인자 타입
export interface ManageDomainArgs { export interface ManageDomainArgs {
action: 'register' | 'check' | 'whois' | 'list' | 'info' | 'get_ns' | 'set_ns' | 'price' | 'cheapest'; action:
| "register"
| "check"
| "whois"
| "list"
| "info"
| "get_ns"
| "set_ns"
| "price"
| "cheapest";
domain?: string; domain?: string;
nameservers?: string[]; nameservers?: string[];
tld?: string; tld?: string;
} }
export interface ManageDepositArgs { export interface ManageDepositArgs {
action: 'balance' | 'account' | 'request' | 'history' | 'cancel' | 'pending' | 'confirm' | 'reject'; action:
| "balance"
| "account"
| "request"
| "history"
| "cancel"
| "pending"
| "confirm"
| "reject";
depositor_name?: string; depositor_name?: string;
amount?: number; amount?: number;
transaction_id?: number; transaction_id?: number;
@@ -168,6 +278,27 @@ export interface SuggestDomainsArgs {
keywords: string; keywords: string;
} }
export interface ManageServerArgs {
action:
| "recommend"
| "list_specs"
| "order"
| "my_servers"
| "server_info"
| "cancel_order";
purpose?: string;
budget?: number;
spec_id?: number;
order_id?: number;
region?: string;
label?: string;
provider?: "linode" | "vultr";
expected_users?: number; // 예상 동시 사용자 수
daily_traffic?: number; // 일일 예상 트래픽 (요청 수)
storage_needs_gb?: number; // 필요한 스토리지 (GB)
tech_stack?: string; // 기술 스택 (nodejs, python, java, php 등)
}
export interface SearchWebArgs { export interface SearchWebArgs {
query: string; query: string;
} }
@@ -282,6 +413,48 @@ export interface BraveSearchResponse {
}; };
} }
// Linode API Types
export interface LinodeInstance {
id: number;
label: string;
status: string;
ipv4: string[];
ipv6: string;
region: string;
type: string;
created: string;
}
export interface LinodeCreateRequest {
type: string;
region: string;
image: string;
root_pass: string;
label?: string;
authorized_keys?: string[];
}
// Vultr API Types
export interface VultrInstance {
id: string;
label: string;
status: string;
main_ip: string;
v6_main_ip: string;
region: string;
plan: string;
date_created: string;
default_password: string;
}
export interface VultrCreateRequest {
plan: string;
region: string;
os_id: number;
label?: string;
hostname?: string;
}
// OpenAI API 응답 타입 // OpenAI API 응답 타입
export interface OpenAIMessage { export interface OpenAIMessage {
role: string; role: string;
@@ -299,11 +472,13 @@ export interface OpenAIResponse {
// Context7 API 응답 타입 // Context7 API 응답 타입
export interface Context7Library { export interface Context7Library {
id: string; id: string;
name: string; title?: string;
name?: string;
} }
export interface Context7SearchResponse { export interface Context7SearchResponse {
libraries?: Context7Library[]; libraries?: Context7Library[];
results?: Context7Library[]; // API가 results로 반환
} }
export interface Context7DocsResponse { export interface Context7DocsResponse {
@@ -315,21 +490,39 @@ export interface Context7DocsResponse {
// Telegram Inline Keyboard 데이터 // Telegram Inline Keyboard 데이터
export interface DomainRegisterKeyboardData { export interface DomainRegisterKeyboardData {
type: 'domain_register'; type: "domain_register";
domain: string; domain: string;
price: number; price: number;
} }
export type KeyboardData = DomainRegisterKeyboardData; export interface ServerOrderKeyboardData {
type: "server_order";
order_id: number;
spec_id: number;
price: number;
region: string;
}
export interface ServerRecommendKeyboardData {
type: "server_recommend";
specs: Array<{
num: number;
plan: string; // 플랜 ID (예: vc2-1c-0.5gb-v6)
region: string; // 리전 코드 (예: nrt, icn)
provider: string; // 제공자 (linode, vultr)
}>;
}
export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerRecommendKeyboardData;
// Workers AI Types (from worker-configuration.d.ts) // Workers AI Types (from worker-configuration.d.ts)
export type WorkersAIModel = export type WorkersAIModel =
| '@cf/meta/llama-3.1-8b-instruct' | "@cf/meta/llama-3.1-8b-instruct"
| '@cf/meta/llama-3.2-3b-instruct' | "@cf/meta/llama-3.2-3b-instruct"
| '@cf/meta/llama-3-8b-instruct'; | "@cf/meta/llama-3-8b-instruct";
export interface WorkersAIMessage { export interface WorkersAIMessage {
role: 'system' | 'user' | 'assistant'; role: "system" | "user" | "assistant";
content: string; content: string;
} }

208
src/utils/session.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* KV 기반 세션 관리 유틸리티
* - 다단계 플로우 (서버 주문, 도메인 등록 등)의 임시 데이터 저장
* - TTL 24시간 자동 만료
*/
export type SessionType = 'server_order' | 'domain_register';
export interface SessionData<T = unknown> {
type: SessionType;
step: string;
data: T;
userId: number;
createdAt: number;
updatedAt: number;
}
// 서버 주문 세션 데이터
export interface ServerOrderSessionData {
// 추천 목록 (선택 전까지 임시 저장)
recommendations?: Array<{
plan: string;
region: string;
provider: string;
}>;
// 추천 정보
purpose?: string;
budget?: number;
expectedUsers?: number;
// 선택된 사양
plan?: string;
provider?: string;
region?: string;
// OS 선택
image?: string;
// 가격 (캐시)
priceKrw?: number;
// 주문 ID (최종 확인 단계)
orderId?: number;
}
// 서버 주문 단계 정의 (step 필드 타입 가이드)
export type ServerOrderStep = 'recommend' | 'spec_confirm' | 'os_select' | 'final_confirm';
const SESSION_TTL_SECONDS = 24 * 60 * 60; // 24시간
/**
* 세션 ID 생성
* format: {type_prefix}_{userId}_{random}
*/
function generateSessionId(type: SessionType, userId: number): string {
const prefix = type === 'server_order' ? 'srv' : 'dom';
const random = crypto.randomUUID().slice(0, 8);
return `${prefix}_${userId}_${random}`;
}
/**
* 세션 생성
*/
export async function createSession<T>(
kv: KVNamespace,
userId: number,
type: SessionType,
initialData: T,
step: string = 'init'
): Promise<string> {
const sessionId = generateSessionId(type, userId);
const now = Date.now();
const session: SessionData<T> = {
type,
step,
data: initialData,
userId,
createdAt: now,
updatedAt: now,
};
await kv.put(
`session:${sessionId}`,
JSON.stringify(session),
{ expirationTtl: SESSION_TTL_SECONDS }
);
// 사용자의 활성 세션 참조 저장 (같은 타입의 이전 세션 덮어쓰기)
await kv.put(
`user_session:${userId}:${type}`,
sessionId,
{ expirationTtl: SESSION_TTL_SECONDS }
);
return sessionId;
}
/**
* 세션 조회
*/
export async function getSession<T>(
kv: KVNamespace,
sessionId: string
): Promise<SessionData<T> | null> {
const raw = await kv.get(`session:${sessionId}`);
if (!raw) return null;
try {
return JSON.parse(raw) as SessionData<T>;
} catch {
return null;
}
}
/**
* 세션 조회 + 권한 검증
*/
export async function getSessionForUser<T>(
kv: KVNamespace,
sessionId: string,
userId: number
): Promise<SessionData<T> | null> {
const session = await getSession<T>(kv, sessionId);
if (!session) return null;
if (session.userId !== userId) return null;
return session;
}
/**
* 사용자의 활성 세션 조회
*/
export async function getUserActiveSession<T>(
kv: KVNamespace,
userId: number,
type: SessionType
): Promise<{ sessionId: string; session: SessionData<T> } | null> {
const sessionId = await kv.get(`user_session:${userId}:${type}`);
if (!sessionId) return null;
const session = await getSession<T>(kv, sessionId);
if (!session) {
// 참조는 있지만 세션이 만료됨 - 참조 정리
await kv.delete(`user_session:${userId}:${type}`);
return null;
}
return { sessionId, session };
}
/**
* 세션 업데이트
*/
export async function updateSession<T>(
kv: KVNamespace,
sessionId: string,
updates: Partial<T> & { step?: string }
): Promise<SessionData<T> | null> {
const session = await getSession<T>(kv, sessionId);
if (!session) return null;
const { step, ...dataUpdates } = updates;
const updated: SessionData<T> = {
...session,
step: step ?? session.step,
data: { ...session.data, ...dataUpdates } as T,
updatedAt: Date.now(),
};
await kv.put(
`session:${sessionId}`,
JSON.stringify(updated),
{ expirationTtl: SESSION_TTL_SECONDS }
);
return updated;
}
/**
* 세션 삭제
*/
export async function deleteSession(
kv: KVNamespace,
sessionId: string
): Promise<void> {
const session = await getSession(kv, sessionId);
await kv.delete(`session:${sessionId}`);
// 사용자 참조도 삭제
if (session) {
await kv.delete(`user_session:${session.userId}:${session.type}`);
}
}
/**
* 세션 만료 여부 확인 (UI용 메시지)
*/
export function isSessionExpired(session: SessionData | null): boolean {
if (!session) return true;
const elapsed = Date.now() - session.createdAt;
return elapsed > SESSION_TTL_SECONDS * 1000;
}

View File

@@ -6,7 +6,7 @@ compatibility_date = "2024-01-01"
binding = "AI" binding = "AI"
[vars] [vars]
ENVIRONMENT = "production" # 환경 설정 (production | development) ENVIRONMENT = "development" # 로컬: development, 배포 시 secrets로 production 설정
SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수) SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수)
MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우) MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우)
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택) N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
@@ -21,14 +21,36 @@ BRAVE_API_BASE = "https://api.search.brave.com/res/v1"
WTTR_IN_URL = "https://wttr.in" WTTR_IN_URL = "https://wttr.in"
HOSTING_SITE_URL = "https://hosting.anvil.it.com" HOSTING_SITE_URL = "https://hosting.anvil.it.com"
# VPS Provider API Endpoints
LINODE_API_BASE = "https://api.linode.com/v4"
VULTR_API_BASE = "https://api.vultr.com/v2"
DEFAULT_SERVER_REGION = "ap-northeast" # 오사카 (Linode: ap-northeast, Vultr: nrt)
SERVER_RECOMMEND_API_URL = "https://server-recommend.kappa-d8e.workers.dev/api/recommend" # 외부 AI 추천 API (선택)
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "telegram-conversations" database_name = "telegram-conversations"
database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
[[d1_databases]]
binding = "CLOUD_DB"
database_name = "cloud-instances-db"
database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8"
[[kv_namespaces]] [[kv_namespaces]]
binding = "RATE_LIMIT_KV" binding = "RATE_LIMIT_KV"
id = "15bcdcbde94046fe936c89b2e7d85b64" id = "15bcdcbde94046fe936c89b2e7d85b64"
preview_id = "0d3af750739e40d4a0324889564d74a7"
[[kv_namespaces]]
binding = "SESSION_KV"
id = "24ee962396cc4e9ab1fb47ceacf62c7d"
preview_id = "302ad556567447cbac49c20bded4eb7e"
# Service Binding: Worker-to-Worker 호출용 (Cloudflare Error 1042 방지)
[[services]]
binding = "SERVER_RECOMMEND"
service = "server-recommend"
# Email Worker 설정 (SMS → 메일 수신) # Email Worker 설정 (SMS → 메일 수신)
# Cloudflare Dashboard에서 Email Routing 설정 필요: # Cloudflare Dashboard에서 Email Routing 설정 필요:
@@ -49,3 +71,6 @@ crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00
# - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동) # - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동)
# - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장) # - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장)
# - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장) # - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장)
# - LINODE_API_KEY: Linode Personal Access Token
# - VULTR_API_KEY: Vultr API Key
# - SERVER_ADMIN_ID: 서버 관리 알림 수신자 Telegram ID