Files
telegram-bot-workers/src/tools/server-tool.ts
kappa 4642c1ff94 refactor: move server-agent to agents directory
- Create src/agents/ directory for agent modules
- Move server-agent.ts to new location
- Update import paths in all dependent files:
  - openai-service.ts
  - tools/server-tool.ts
  - routes/handlers/message-handler.ts
  - routes/api/chat.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 12:47:34 +09:00

1500 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Env,
ProvisionResponse,
ProvisionOrder,
OSImage,
RecommendResponse
} from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger, maskUserId } from '../utils/logger';
import { ERROR_MESSAGES } from '../constants/messages';
import { formatTrafficInfo } from '../utils/formatters';
const logger = createLogger('server-tool');
const provisionLogger = createLogger('provision');
// Generate idempotency key for order requests
// Format: tg-order-{userId}-{timestamp}-{random}
function generateIdempotencyKey(userId: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
return `tg-order-${userId}-${timestamp}-${random}`;
}
// CDN 캐시 히트율 상수
const CDN_CACHE_HIT_RATES = {
VIDEO_STREAMING: 0.92,
STATIC_SITE: 0.95,
API: 0.30,
ECOMMERCE: 0.70,
DEFAULT: 0.85,
} as const;
// 언어 감지 (한글/일본어/중국어/영어)
function detectLanguage(text: string): 'ko' | 'ja' | 'zh' | 'en' {
if (/[가-힣]/.test(text)) return 'ko';
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'ja'; // 히라가나/가타카나
if (/[\u4e00-\u9fff]/.test(text)) return 'zh'; // 한자 (일본어 감지 후)
return 'en';
}
// CDN 캐시 히트율 추정 (tech_stack + use_case 기반)
function estimateCdnCacheHitRate(techStack: string[], useCase: string): number | null {
const stackLower = techStack.map(s => s.toLowerCase());
const useCaseLower = useCase.toLowerCase();
// CDN 키워드 감지
const hasCdn = stackLower.some(s =>
['cloudflare', 'cdn', 'fastly', 'akamai', 'bunny', 'cf'].includes(s)
);
if (!hasCdn) return null; // CDN 없으면 null 반환
// use_case 기반 히트율 조정
const isVideoStreaming = /video|streaming|vod|media|동영상|스트리밍|미디어/.test(useCaseLower);
const isStaticSite = /static|blog|portfolio|landing|정적|블로그/.test(useCaseLower);
const isApi = /api|backend|서버|백엔드/.test(useCaseLower);
const isEcommerce = /shop|store|commerce|쇼핑|이커머스/.test(useCaseLower);
// 콘텐츠 타입별 예상 캐시 히트율
if (isVideoStreaming) return CDN_CACHE_HIT_RATES.VIDEO_STREAMING;
if (isStaticSite) return CDN_CACHE_HIT_RATES.STATIC_SITE;
if (isApi) return CDN_CACHE_HIT_RATES.API;
if (isEcommerce) return CDN_CACHE_HIT_RATES.ECOMMERCE;
return CDN_CACHE_HIT_RATES.DEFAULT;
}
// Type guards
function isErrorResult(result: unknown): result is { error: string } {
return typeof result === 'object' && result !== null && 'error' in result;
}
// 진행 중인 주문 확인 (중복 주문 방지)
async function checkExistingOrder(
db: D1Database,
telegramUserId: string
): Promise<{ id: number; status: string; label: string | null } | null> {
const result = await db.prepare(
`SELECT id, status, label FROM server_orders
WHERE telegram_user_id = ?
AND status IN ('pending', 'provisioning')
LIMIT 1`
).bind(telegramUserId).first<{ id: number; status: string; label: string | null }>();
return result || null;
}
export const manageServerTool = {
type: 'function',
function: {
name: 'manage_server',
description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 추천(action="start_consultation"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot',
'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename'],
description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, start_consultation: 상담 시작, rename: 이름 변경',
},
tech_stack: {
type: 'array',
items: { type: 'string' },
description: '기술 스택. 용도에서 추론 (블로그→wordpress, 쇼핑몰→ecommerce, 커뮤니티→php,mysql). 모르면 ["web"]',
},
expected_users: {
type: 'number',
description: '예상 사용자 수. 모르면 개인용=100, 사업용=500 사용',
},
use_case: {
type: 'string',
description: '용도 (예: "블로그", "쇼핑몰", "커뮤니티")',
},
traffic_pattern: {
type: 'string',
enum: ['steady', 'spiky', 'growing'],
description: '생략 가능. 기본값: steady',
},
region_preference: {
type: 'array',
items: { type: 'string' },
description: '선호 리전 (예: ["tokyo", "seoul"]). recommend action에서 선택',
},
budget_limit: {
type: 'number',
description: '월 예산 한도 (원). recommend action에서 선택',
},
lang: {
type: 'string',
enum: ['ko', 'ja', 'zh', 'en'],
description: '응답 언어. 자동 감지됨',
},
server_id: {
type: 'string',
description: '서버 ID (레거시, 사용 안 함)',
},
region_code: {
type: 'string',
description: '리전 코드 (레거시, 사용 안 함)',
},
label: {
type: 'string',
description: '서버 라벨 (예: "myapp-prod"). order action에서 필수',
},
message: {
type: 'string',
description: '사용자 메시지. continue_consultation action에서 필수',
},
pricing_id: {
type: 'number',
description: 'Pricing ID (서버 스펙 ID). order action에서 필수',
},
order_id: {
type: 'number',
description: '주문 번호. info, delete, rename action에서 필수',
},
new_label: {
type: 'string',
description: '새 서버 이름. rename action에서 필수',
},
image: {
type: 'string',
description: 'OS 이미지 키 (예: "ubuntu_22_04"). order action에서 선택 (기본값 사용 가능)',
},
},
required: ['action'],
},
},
};
// Cloud Orchestrator API 호출
async function callCloudOrchestratorApi(
endpoint: string,
method: string,
body?: Record<string, unknown>,
env?: Env
): Promise<unknown> {
logger.info('API 호출 시작', {
endpoint,
method,
useServiceBinding: !!env?.CLOUD_ORCHESTRATOR
});
try {
const response = await retryWithBackoff(
() => {
const requestInit = {
method,
headers: {
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
};
// Service Binding 우선, fallback: URL
if (env?.CLOUD_ORCHESTRATOR) {
logger.info('Service Binding 사용', { endpoint });
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${endpoint}`, requestInit);
} else {
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
const url = `${apiUrl}${endpoint}`;
logger.info('HTTP 요청 사용', { url });
return fetch(url, requestInit);
}
},
{ maxRetries: 3, serviceName: 'cloud-orchestrator' }
);
if (!response.ok) {
const errorText = await response.text();
logger.error('API 호출 실패', new Error(errorText), {
endpoint,
status: response.status,
});
return { error: `서버 API 호출 실패: HTTP ${response.status}` };
}
const data = await response.json();
logger.info('API 호출 성공', { endpoint });
return data;
} catch (error) {
logger.error('API 호출 에러', error as Error, { endpoint });
if (error instanceof RetryError) {
return { error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE };
}
return { error: '서버 API 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' };
}
}
// Cloud Orchestrator Provision API 호출
async function callProvisionAPI(
endpoint: string,
method: 'GET' | 'POST' | 'DELETE',
env: Env,
body?: Record<string, unknown>,
userId?: string
): Promise<ProvisionResponse> {
provisionLogger.info('API 호출 시작', {
endpoint,
method,
userId: maskUserId(userId),
useServiceBinding: !!env?.CLOUD_ORCHESTRATOR
});
try {
const response = await retryWithBackoff(
() => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Add API Key if available
if (env?.PROVISION_API_KEY) {
headers['X-API-Key'] = env.PROVISION_API_KEY;
}
const requestInit = {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
};
// Add userId as query param for GET/DELETE/POST
let fullEndpoint = endpoint;
if (userId && !endpoint.includes('?')) {
fullEndpoint = `${endpoint}?user_id=${userId}`;
} else if (userId && endpoint.includes('?')) {
fullEndpoint = `${endpoint}&user_id=${userId}`;
}
// Service Binding 우선, fallback: HTTP
if (env?.CLOUD_ORCHESTRATOR) {
provisionLogger.info('Service Binding 사용', { endpoint: fullEndpoint });
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${fullEndpoint}`, requestInit);
} else {
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
const url = `${apiUrl}${fullEndpoint}`;
provisionLogger.info('HTTP 요청 사용 (fallback)', { url });
return fetch(url, requestInit);
}
},
{ maxRetries: 3, serviceName: 'cloud-orchestrator-provision' }
);
if (!response.ok) {
const errorText = await response.text();
provisionLogger.error('API 호출 실패', new Error(errorText), {
endpoint,
status: response.status,
});
// JSON 응답에서 오류 메시지 추출
let errorMessage = `HTTP ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error) {
errorMessage = errorJson.error;
}
} catch {
// JSON 파싱 실패 시 텍스트 그대로 사용
if (errorText && errorText.length < 200) {
errorMessage = errorText;
}
}
return {
success: false,
error: `프로비저닝 API 호출 실패: ${errorMessage}`,
};
}
const data = await response.json() as ProvisionResponse;
provisionLogger.info('API 호출 성공', { endpoint, success: data.success });
return data;
} catch (error) {
provisionLogger.error('API 호출 에러', error as Error, { endpoint });
if (error instanceof RetryError) {
return {
success: false,
error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE,
};
}
return {
success: false,
error: '프로비저닝 API 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
};
}
}
// 추천 데이터 조회 (포맷팅 없이 원본 반환)
export async function getRecommendationData(
args: {
tech_stack: string[];
expected_users: number;
use_case: string;
traffic_pattern?: string;
region_preference?: string[];
budget_limit?: number;
lang?: string;
},
env?: Env
): Promise<RecommendResponse | null> {
const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args;
// 언어 자동 감지
const detectedLang = lang || detectLanguage(use_case);
// CDN 캐시 히트율 추정
const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case);
// API 요청 body 구성
const requestBody: Record<string, unknown> = {
tech_stack,
expected_users,
use_case,
lang: detectedLang,
};
if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern;
if (region_preference) requestBody.region_preference = region_preference;
if (budget_limit) requestBody.budget_limit = budget_limit;
if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate;
// API 호출
const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env);
if (isErrorResult(result)) {
logger.error('추천 데이터 조회 실패', new Error(result.error));
return null;
}
return result as RecommendResponse;
}
// 서버 추천 결과 포맷팅 (필수 정보만)
// __DIRECT__ 마커로 AI 재해석 없이 바로 반환
function formatRecommendations(data: RecommendResponse): string {
if (!data.recommendations || data.recommendations.length === 0) {
return '🔍 조건에 맞는 서버를 찾지 못했습니다.\n다른 조건으로 다시 시도해주세요.';
}
const { recommendations } = data;
// __DIRECT__ 마커 추가 - AI 재해석 방지
let response = '__DIRECT__\n🖥 서버 추천 결과\n\n';
recommendations.slice(0, 3).forEach((rec, index) => {
const server = rec.server;
// 가격 포맷팅 (항상 KRW로 표시)
const price = `${Math.round(server.monthly_price).toLocaleString()}`;
// 대역폭 포맷팅
const bandwidth = server.transfer_tb ? `${server.transfer_tb}TB` : '무제한';
response += `${index + 1}️⃣ ${server.instance_name} (${server.provider_name})\n`;
response += ` • 스펙: ${server.vcpu}vCPU / ${server.memory_gb}GB / ${server.storage_gb}GB SSD\n`;
response += ` • 리전: ${server.region_name} (${server.country_code})\n`;
response += ` • 가격: ${price}/월 (대역폭 ${bandwidth})\n`;
// 대역폭 정보 (항상 표시)
if (rec.bandwidth_info) {
const trafficInfo = formatTrafficInfo(rec.bandwidth_info);
response += `${trafficInfo}\n`;
// 총 예상 비용 (초과 있을 때만 표시, 항상 KRW로 표시)
if (rec.bandwidth_info.estimated_overage_tb > 0 && rec.bandwidth_info.estimated_overage_cost > 0) {
const totalCost = `${Math.round(rec.bandwidth_info.total_estimated_cost).toLocaleString()}`;
response += ` • 총 예상 비용: ${totalCost}/월\n`;
}
}
response += ` • 점수: ${rec.score}`;
if (rec.estimated_capacity) {
response += ` / 최대 ${rec.estimated_capacity.max_concurrent_users.toLocaleString()}\n`;
} else {
response += '\n';
}
response += '\n';
});
// 환불 정책 안내 추가
response += '\n💡 환불 정책: 720시간 = 1개월 기준\n';
response += ' 해지 시 미사용 시간은 시간당 요금으로 환불\n\n';
// 선택 가이드 추가
response += '💡 원하는 서버를 선택하려면 번호를 입력하세요 (예: 1번)';
return response.trim();
}
// 서버 상태 이모지
function getStatusEmoji(status: string): string {
switch (status) {
case 'active':
return '🟢';
case 'provisioning':
return '🔄';
case 'stopped':
return '⛔';
case 'deleted':
case 'terminated':
return '🗑️';
case 'failed':
return '❌';
case 'pending':
return '⏳'; // 내부용, UI에 표시 안 함
default:
return '⚪';
}
}
// 서버 상태 텍스트
function getStatusText(status: string): string {
switch (status) {
case 'active':
return '가동 중';
case 'provisioning':
return '생성 중...';
case 'stopped':
return '중지됨';
case 'deleted':
case 'terminated':
return '삭제됨';
case 'failed':
return '실패';
default:
return '알 수 없음';
}
}
// 날짜 포맷팅 (YYYY-MM-DD)
function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
return date.toISOString().split('T')[0];
} catch {
return dateString;
}
}
// 남은 시간 계산 (만료일 기준)
function calculateRemainingTime(expiresAt: string): string {
try {
const expiresTime = new Date(expiresAt).getTime();
const now = Date.now();
const remainingMs = expiresTime - now;
if (remainingMs <= 0) {
return '만료됨';
}
const remainingHours = Math.floor(remainingMs / (1000 * 60 * 60));
const remainingDays = Math.floor(remainingHours / 24);
const hours = remainingHours % 24;
if (remainingDays > 0) {
return `${remainingDays}${hours}시간`;
} else {
return `${hours}시간`;
}
} catch {
return '알 수 없음';
}
}
// 만료일 포맷팅 (YYYY-MM-DD HH:MM)
function formatExpiry(expiresAt: string): string {
try {
const date = new Date(expiresAt);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
} catch {
return expiresAt;
}
}
// 서버 목록 포맷팅
function formatServerList(orders: ProvisionOrder[]): string {
// 'pending' 상태는 내부용 (Queue 대기), UI에 표시하지 않음
// 'provisioning' 또는 'active' 상태만 표시
// 'terminated', 'deleted', 'failed' 상태는 제외
const activeOrders = orders?.filter(order => {
// pending은 표시하지 않음 (내부 Queue 상태)
if (order.status === 'pending') {
return false;
}
// provisioning 또는 active만 표시
if (!['provisioning', 'active'].includes(order.status)) {
return false;
}
// active 상태인데 provider_instance_id가 없으면 제외 (프로비저닝 실패)
if (order.status === 'active' && !order.provider_instance_id) {
return false;
}
return true;
}) || [];
if (activeOrders.length === 0) {
return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.';
}
let response = '__DIRECT__\n🖥 내 서버 목록\n\n';
activeOrders.forEach((order) => {
const statusEmoji = getStatusEmoji(order.status);
const statusText = getStatusText(order.status);
const label = order.label || '(라벨 없음)';
response += `#${order.id} ${statusEmoji} ${label}\n`;
if (order.ip_address) {
response += ` • IP: ${order.ip_address}\n`;
}
response += ` • 상태: ${statusText}\n`;
response += ` • 생성일: ${formatDate(order.created_at)}\n`;
// 만료일 표시 (있을 경우)
if (order.expires_at) {
const remainingTime = calculateRemainingTime(order.expires_at);
response += ` • 만료일: ${formatExpiry(order.expires_at)}\n`;
response += ` • 남은 시간: ${remainingTime}\n`;
}
response += '\n';
});
response += '💡 서버 관리: "N번 시작/중지" 또는 "#N 재시작"';
return response.trim();
}
// 서버 상세 정보 포맷팅
function formatServerInfo(order: ProvisionOrder): string {
const statusEmoji = getStatusEmoji(order.status);
const statusText = getStatusText(order.status);
let response = '__DIRECT__\n🖥 서버 상세 정보\n\n';
response += '📋 기본 정보\n';
response += `• 주문번호: #${order.id}\n`;
response += `• 라벨: ${order.label}\n`;
response += `• 상태: ${statusEmoji} ${statusText}\n\n`;
if (order.status === 'active' && order.ip_address) {
response += '🔗 접속 정보\n';
response += `• IP: ${order.ip_address}\n`;
if (order.image) {
response += `• OS: ${order.image.replace(/_/g, ' ')}\n`;
}
if (order.root_password) {
response += `• Password: ${order.root_password}\n\n`;
response += '⚠️ 비밀번호는 안전한 곳에 보관하세요.\n';
}
} else if (order.status === 'provisioning') {
response += '⏳ 서버 생성 중입니다.\n잠시 후 다시 조회해주세요.\n';
}
return response.trim();
}
// OS 이미지 목록 포맷팅
function formatImageList(images: OSImage[]): string {
if (!images || images.length === 0) {
return '📀 사용 가능한 OS 이미지가 없습니다.';
}
let response = '__DIRECT__\n📀 사용 가능한 OS 이미지\n\n';
images.forEach((image) => {
// is_default can be boolean or number (1/0)
const isDefault = image.is_default === true || image.is_default === 1;
const marker = isDefault ? '✓ ' : ' ';
const defaultText = isDefault ? ' (기본)' : '';
response += `${marker}${image.name}${defaultText}\n`;
});
return response.trim();
}
// 입금 계좌 정보
const DEPOSIT_ACCOUNT_INFO = `💳 입금 계좌
하나은행 427-910018-27104
예금주: (주)아이언클래드
입금 후 "입금 신고"로 알려주세요.`;
// 서버 작업 직접 실행
export async function executeServerAction(
action: string,
args: {
tech_stack?: string[];
expected_users?: number;
use_case?: string;
traffic_pattern?: string;
region_preference?: string[];
budget_limit?: number;
lang?: string;
server_id?: string;
region_code?: string;
label?: string;
message?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
image?: string;
},
env?: Env,
telegramUserId?: string
): Promise<string> {
logger.info('작업 시작', {
action,
userId: maskUserId(telegramUserId),
args: JSON.stringify(args).slice(0, 200),
});
switch (action) {
case 'start_consultation': {
// Import session functions
const { saveServerSession } = await import('../agents/server-agent');
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env?.DB) {
return '🚫 세션 저장소가 설정되지 않았습니다.';
}
const session: import('../types').ServerSession = {
telegramUserId,
status: 'gathering',
collectedInfo: {},
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
await saveServerSession(env.DB, telegramUserId, session);
logger.info('상담 세션 생성', { userId: maskUserId(telegramUserId) });
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
}
case 'continue_consultation': {
const { getServerSession, processServerConsultation } = await import('../agents/server-agent');
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env?.DB) {
return '🚫 세션 저장소가 설정되지 않았습니다.';
}
if (!args.message) {
return '🚫 메시지가 필요합니다.';
}
const session = await getServerSession(env.DB, telegramUserId);
if (!session) {
return '세션이 만료되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
}
const result = await processServerConsultation(args.message, session, env);
return result;
}
case 'cancel_consultation': {
const { deleteServerSession } = await import('../agents/server-agent');
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env?.DB) {
return '🚫 세션 저장소가 설정되지 않았습니다.';
}
await deleteServerSession(env.DB, telegramUserId);
logger.info('상담 세션 취소', { userId: maskUserId(telegramUserId) });
return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
}
case 'recommend': {
const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args;
// 필수 파라미터 검증
if (!tech_stack || !expected_users || !use_case) {
return '🚫 서버 추천에는 tech_stack, expected_users, use_case가 필요합니다.';
}
// 언어 자동 감지 (use_case 기반)
const detectedLang = lang || detectLanguage(use_case);
// CDN 캐시 히트율 추정
const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case);
// API 요청 body 구성
const requestBody: Record<string, unknown> = {
tech_stack,
expected_users,
use_case,
lang: detectedLang,
};
if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern;
if (region_preference) requestBody.region_preference = region_preference;
if (budget_limit) requestBody.budget_limit = budget_limit;
if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate;
// API 호출
const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env);
if (isErrorResult(result)) {
return `🚫 ${result.error}`;
}
const recommendationData = result as RecommendResponse;
// 세션에 추천 결과 저장 (선택 기능 활성화)
if (telegramUserId && env?.DB && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
try {
const { getServerSession, saveServerSession } = await import('../agents/server-agent');
// 기존 세션 조회 또는 새로 생성
let session = await getServerSession(env.DB, telegramUserId);
if (!session) {
// 세션이 없으면 새로 생성
session = {
telegramUserId,
status: 'selecting',
collectedInfo: {
useCase: use_case,
scale: expected_users <= 50 ? 'personal' : 'business',
expectedConcurrent: expected_users,
},
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
logger.info('새 세션 생성 (추천 결과 저장용)', { userId: telegramUserId });
}
// lastRecommendation 저장
session.lastRecommendation = {
recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({
pricing_id: rec.server.id,
plan_name: rec.server.instance_name,
provider: rec.server.provider_name,
specs: {
vcpu: rec.server.vcpu,
ram_gb: rec.server.memory_gb,
storage_gb: rec.server.storage_gb
},
region: {
code: rec.server.region_code,
name: rec.server.region_name
},
price: {
monthly_krw: Math.round(rec.server.monthly_price),
bandwidth_tb: rec.server.transfer_tb,
estimated_monthly_tb: rec.bandwidth_info?.estimated_monthly_tb,
gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb,
cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate,
overage_tb: rec.bandwidth_info?.estimated_overage_tb,
overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost,
currency: rec.server.currency,
},
score: rec.score,
max_users: rec.estimated_capacity?.max_concurrent_users || 0
})),
createdAt: Date.now()
};
// status를 'selecting'으로 변경
session.status = 'selecting';
session.updatedAt = Date.now();
await saveServerSession(env.DB, telegramUserId, session);
logger.info('추천 결과 세션 저장 완료', {
userId: telegramUserId,
recommendationCount: session.lastRecommendation.recommendations.length,
status: session.status
});
} catch (sessionError) {
logger.error('세션 저장 실패 (무시)', sessionError as Error, { userId: telegramUserId });
// 세션 저장 실패해도 추천 결과는 반환
}
}
// 결과 포맷팅
return formatRecommendations(recommendationData);
}
case 'order': {
const { pricing_id, label, image } = args;
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!pricing_id || !label) {
return '🚫 서버 주문에는 pricing_id와 label이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
// 중복 주문 방지: 진행 중인 주문 확인
if (env.DB) {
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
if (existingOrder) {
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
return `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
`• 주문 번호: #${existingOrder.id}\n` +
`• 라벨: ${existingOrder.label || '없음'}\n` +
`• 상태: ${statusText}\n\n` +
`완료 후 다시 시도해주세요.`;
}
}
// Check balance first
const balanceResult = await callProvisionAPI(
'/api/provision/balance',
'GET',
env,
undefined,
telegramUserId
);
if (balanceResult.error || balanceResult.balance_krw === undefined) {
return `🚫 잔액 조회 실패: ${balanceResult.error || '알 수 없는 오류'}`;
}
// Get pricing info to check if balance is sufficient
// For now, we'll proceed with the order and let the API handle balance validation
// Generate idempotency key to prevent duplicate orders on network retries
const idempotencyKey = generateIdempotencyKey(telegramUserId);
const orderBody: Record<string, unknown> = {
user_id: telegramUserId,
pricing_id,
label,
dry_run: false,
idempotency_key: idempotencyKey,
};
if (image) {
orderBody.image = image;
}
const result = await callProvisionAPI(
'/api/provision',
'POST',
env,
orderBody,
telegramUserId
);
if (result.error || !result.order) {
// Check if it's a balance error (error_code first, then fallback to text matching)
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
(result.error && result.error.includes('balance'))) {
return `⚠️ 잔액이 부족합니다\n\n• 현재 잔액: ₩${balanceResult.balance_krw.toLocaleString()}\n\n${DEPOSIT_ACCOUNT_INFO}`;
}
return `🚫 서버 주문 실패: ${result.error || '알 수 없는 오류'}`;
}
const order = result.order;
const statusEmoji = getStatusEmoji(order.status);
const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${statusEmoji} ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`;
return response;
}
case 'start': {
const { order_id } = args;
if (!order_id) {
return '🚫 서버 시작에는 order_id가 필요합니다.';
}
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
// Call the provision API to start the server
const result = await callProvisionAPI(
`/api/provision/orders/${order_id}/start`,
'POST',
env,
undefined,
telegramUserId
);
if (result.error) {
return `🚫 서버 시작 실패: ${result.error}`;
}
logger.info('서버 시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
return `__DIRECT__\n✅ 서버 시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 시작 중...\n\n⏳ 서버가 시작되기까지 1-2분 소요될 수 있습니다.`;
}
case 'stop': {
const { order_id } = args;
if (!order_id) {
return '🚫 서버 중지에는 order_id가 필요합니다.';
}
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
// Call the provision API to stop the server
const result = await callProvisionAPI(
`/api/provision/orders/${order_id}/stop`,
'POST',
env,
undefined,
telegramUserId
);
if (result.error) {
return `🚫 서버 중지 실패: ${result.error}`;
}
logger.info('서버 중지 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
return `__DIRECT__\n✅ 서버 중지 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 중지 중...\n\n⏳ 서버가 중지되기까지 1-2분 소요될 수 있습니다.`;
}
case 'reboot': {
const { order_id } = args;
if (!order_id) {
return '🚫 서버 재시작에는 order_id가 필요합니다.';
}
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
// Call the provision API to reboot the server
const result = await callProvisionAPI(
`/api/provision/orders/${order_id}/reboot`,
'POST',
env,
undefined,
telegramUserId
);
if (result.error) {
return `🚫 서버 재시작 실패: ${result.error}`;
}
logger.info('서버 재시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
return `__DIRECT__\n✅ 서버 재시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 재시작 중...\n\n⏳ 서버가 재시작되기까지 2-3분 소요될 수 있습니다.`;
}
case 'list': {
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
const result = await callProvisionAPI(
'/api/provision/orders',
'GET',
env,
undefined,
telegramUserId
);
// API returns orders directly without success field
if (result.error) {
return `🚫 서버 목록 조회 실패: ${result.error}`;
}
if (!result.orders) {
return `🚫 서버 목록 조회 실패: 응답 형식 오류`;
}
return formatServerList(result.orders);
}
case 'info': {
const { order_id } = args;
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!order_id) {
return '🚫 서버 상세 조회에는 order_id가 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
const result = await callProvisionAPI(
`/api/provision/orders/${order_id}`,
'GET',
env,
undefined,
telegramUserId
);
if (result.error) {
return `🚫 서버 정보 조회 실패: ${result.error}`;
}
if (!result.order) {
return `🚫 서버 정보 조회 실패: 해당 서버를 찾을 수 없습니다`;
}
return formatServerInfo(result.order);
}
case 'delete': {
const { order_id } = args;
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!order_id) {
return '🚫 서버 해지에는 order_id가 필요합니다.';
}
if (!env || !env.SESSION_KV) {
return '🚫 환경 설정이 필요합니다.';
}
// First get the order info
const infoResult = await callProvisionAPI(
`/api/provision/orders/${order_id}`,
'GET',
env,
undefined,
telegramUserId
);
if (infoResult.error || !infoResult.order) {
return `🚫 서버 정보 조회 실패: ${infoResult.error || '해당 서버를 찾을 수 없습니다'}`;
}
const order = infoResult.order;
// Store pending deletion in SESSION_KV (5 minutes TTL)
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
const deleteSessionData = JSON.stringify({
orderId: order.id,
label: order.label,
timestamp: Date.now(),
});
await env.SESSION_KV.put(deleteSessionKey, deleteSessionData, {
expirationTtl: 300, // 5 minutes
});
logger.info('삭제 대기 세션 생성', { userId: telegramUserId, orderId: order.id });
// Return confirmation message (no keyboard)
let response = '__DIRECT__\n⚠ 서버 삭제 확인\n\n';
response += '📋 삭제할 서버 정보\n';
response += `• 주문번호: #${order.id}\n`;
response += `• 라벨: ${order.label}\n`;
if (order.ip_address) {
response += `• IP: ${order.ip_address}\n`;
}
response += '\n';
response += '🚨 경고: 이 작업은 되돌릴 수 없습니다!\n';
response += '• 서버의 모든 데이터가 삭제됩니다\n';
response += '• 백업을 먼저 진행해주세요\n\n';
response += '정말 삭제하려면 "삭제"라고 입력하세요.\n';
response += '취소하려면 다른 메시지를 입력하거나 5분간 기다리세요.';
return response;
}
case 'images': {
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
const result = await callProvisionAPI(
'/api/provision/images',
'GET',
env
);
if (result.error) {
return `🚫 이미지 목록 조회 실패: ${result.error}`;
}
if (!result.images) {
return `🚫 이미지 목록 조회 실패: 응답 형식 오류`;
}
return formatImageList(result.images);
}
case 'rename': {
const { order_id, new_label } = args;
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!order_id) {
return '🚫 이름 변경할 서버 번호를 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
}
if (!new_label) {
return '🚫 새 서버 이름을 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
}
if (!env || !env.DB) {
return '🚫 환경 설정이 필요합니다.';
}
// 서버 소유권 확인 및 이름 변경
const server = await env.DB.prepare(
`SELECT id, label FROM server_orders WHERE id = ? AND telegram_user_id = ?`
).bind(order_id, telegramUserId).first<{ id: number; label: string | null }>();
if (!server) {
return '🚫 해당 서버를 찾을 수 없거나 권한이 없습니다.';
}
const oldLabel = server.label || `서버 #${server.id}`;
await env.DB.prepare(
`UPDATE server_orders SET label = ?, updated_at = datetime('now') WHERE id = ?`
).bind(new_label, order_id).run();
logger.info('서버 이름 변경', { orderId: order_id, oldLabel, newLabel: new_label, userId: maskUserId(telegramUserId) });
return `✅ 서버 이름이 변경되었습니다.
• 이전: ${oldLabel}
• 변경: ${new_label}`;
}
default:
return `🚫 알 수 없는 작업: ${action}`;
}
}
// 서버 삭제 시 환불액 계산 (시간당 요금제)
function calculateRefund(pricePaid: number, createdAt: string): { refundAmount: number; usedHours: number; hourlyRate: number } {
const HOURS_PER_MONTH = 720; // 30일 * 24시간
const hourlyRate = pricePaid / HOURS_PER_MONTH;
const createdTime = new Date(createdAt).getTime();
const now = Date.now();
const usedMs = now - createdTime;
const usedHours = Math.ceil(usedMs / (1000 * 60 * 60)); // 올림 처리
const usedAmount = Math.ceil(hourlyRate * usedHours);
const refundAmount = Math.max(0, pricePaid - usedAmount);
return { refundAmount, usedHours, hourlyRate };
}
// 실제 서버 삭제 실행 (callback_query에서 호출)
export async function executeServerDelete(
orderId: number,
telegramUserId: string,
env: Env
): Promise<{ success: boolean; message: string }> {
provisionLogger.info('서버 삭제 실행', {
orderId,
userId: maskUserId(telegramUserId),
});
// Get order info for refund calculation
const infoResult = await callProvisionAPI(
`/api/provision/orders/${orderId}`,
'GET',
env,
undefined,
telegramUserId
);
if (!infoResult.order) {
return {
success: false,
message: `🚫 서버 정보 조회 실패: ${infoResult.error || '해당 서버를 찾을 수 없습니다'}`,
};
}
const order = infoResult.order;
const orderLabel = order.label || `#${orderId}`;
const pricePaid = order.price_paid || 0;
// Delete the order first
const result = await callProvisionAPI(
`/api/provision/orders/${orderId}`,
'DELETE',
env,
undefined,
telegramUserId
);
if (result.error) {
provisionLogger.error('서버 삭제 실패', new Error(result.error), { orderId });
return {
success: false,
message: `🚫 서버 종료 실패: ${result.error}`,
};
}
// Update terminated_at in local DB
try {
await env.DB.prepare(`
UPDATE server_orders
SET terminated_at = datetime('now'), status = 'terminated', updated_at = datetime('now')
WHERE id = ?
`).bind(orderId).run();
} catch (dbError) {
provisionLogger.error('terminated_at 업데이트 실패 (무시)', dbError as Error, { orderId });
}
// Calculate refund
let refundMessage = '';
if (pricePaid > 0 && order.created_at) {
const { refundAmount, usedHours } = calculateRefund(pricePaid, order.created_at);
if (refundAmount > 0) {
try {
// Get user from DB
const userResult = await env.DB.prepare(
'SELECT id FROM users WHERE telegram_id = ?'
).bind(telegramUserId).first<{ id: number }>();
if (userResult) {
// Get balance before refund
const balanceBefore = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(userResult.id).first<{ balance: number }>();
const beforeBalance = balanceBefore?.balance ?? 0;
// Add refund to user_deposits (with version increment for optimistic locking)
await env.DB.prepare(`
UPDATE user_deposits
SET balance = balance + ?, version = version + 1
WHERE user_id = ?
`).bind(refundAmount, userResult.id).run();
// Record refund transaction
await env.DB.prepare(`
INSERT INTO deposit_transactions (user_id, type, amount, status, description, depositor_name, created_at, confirmed_at)
VALUES (?, 'refund', ?, 'confirmed', ?, '시스템', datetime('now'), datetime('now'))
`).bind(userResult.id, refundAmount, `서버 해지 환불: ${orderLabel}`).run();
const afterBalance = beforeBalance + refundAmount;
refundMessage = `\n\n💰 환불 정보\n• 환불 전 잔액: ${beforeBalance.toLocaleString()}\n• 환불 금액: +${refundAmount.toLocaleString()}\n• 환불 후 잔액: ${afterBalance.toLocaleString()}`;
provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours, beforeBalance, afterBalance });
}
} catch (refundError) {
provisionLogger.error('환불 처리 실패', refundError as Error, { orderId, refundAmount });
refundMessage = '\n\n⚠ 환불 처리 중 오류가 발생했습니다. 관리자에게 문의해주세요.';
}
} else {
refundMessage = `\n\n💰 환불 정보\n• 결제 금액: ${pricePaid.toLocaleString()}\n• 사용 시간: ${usedHours}시간\n• 환불 금액: 0원 (사용 기간 초과)`;
}
}
// Clear server consultation session (if any)
try {
const { deleteServerSession } = await import('../server-agent');
await deleteServerSession(env.DB, telegramUserId);
} catch (error) {
provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error);
}
provisionLogger.info('서버 삭제 완료', { orderId });
return {
success: true,
message: `✅ 서버가 종료되었습니다.\n\n• 주문번호: #${orderId}\n• 라벨: ${orderLabel}${refundMessage}`,
};
}
// 실제 서버 주문 실행 (텍스트 확인에서 호출)
export async function executeServerOrder(
orderData: {
userId: string;
index: number;
plan: string;
pricingId: number;
region: string;
label: string;
},
telegramUserId: string,
env: Env
): Promise<{ success: boolean; message: string }> {
provisionLogger.info('서버 주문 실행', {
userId: maskUserId(telegramUserId),
plan: orderData.plan,
pricingId: orderData.pricingId,
});
// 중복 주문 방지: 진행 중인 주문 확인
if (env.DB) {
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
if (existingOrder) {
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
return {
success: false,
message: `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
`• 주문 번호: #${existingOrder.id}\n` +
`• 라벨: ${existingOrder.label || '없음'}\n` +
`• 상태: ${statusText}\n\n` +
`완료 후 다시 시도해주세요.`,
};
}
}
// Generate idempotency key to prevent duplicate orders on network retries
const idempotencyKey = generateIdempotencyKey(telegramUserId);
// Call provision API
const result = await callProvisionAPI(
'/api/provision',
'POST',
env,
{
user_id: telegramUserId,
pricing_id: orderData.pricingId,
label: orderData.label,
idempotency_key: idempotencyKey,
},
telegramUserId
);
if (result.error) {
provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData });
// Check for specific error types (error_code first, then fallback to text matching)
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
result.error.includes('INSUFFICIENT_BALANCE') ||
result.error.includes('잔액')) {
return {
success: false,
message: `💰 잔액이 부족합니다.\n\n입금 후 다시 시도해주세요.\n\n📌 입금 계좌\n하나은행 427-910018-27104\n(주)아이언클래드`,
};
}
return {
success: false,
message: `🚫 서버 신청 실패: ${result.error}`,
};
}
const order = result.order;
provisionLogger.info('서버 주문 완료', { orderId: order?.id, plan: orderData.plan });
// Clear server consultation session
try {
const { deleteServerSession } = await import('../server-agent');
await deleteServerSession(env.DB, telegramUserId);
} catch (error) {
provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error);
}
// Build success message
let successMessage = `✅ 서버 신청이 완료되었습니다!\n\n`;
successMessage += `📋 주문 정보\n`;
successMessage += `• 주문번호: #${order?.id || 'N/A'}\n`;
successMessage += `• 플랜: ${orderData.plan}\n`;
successMessage += `• 라벨: ${orderData.label}\n`;
if (order?.ip_address) {
successMessage += `\n🌐 서버 정보\n`;
successMessage += `• IP 주소: ${order.ip_address}\n`;
successMessage += `• 비밀번호: 보안을 위해 별도로 조회하세요\n`;
} else {
successMessage += `\n⏳ 서버가 프로비저닝 중입니다.\n`;
successMessage += `잠시 후 "내 서버" 명령으로 상태를 확인해주세요.`;
}
return {
success: true,
message: successMessage,
};
}
export async function executeManageServer(
args: {
action: string;
tech_stack?: string[];
expected_users?: number;
use_case?: string;
traffic_pattern?: string;
region_preference?: string[];
budget_limit?: number;
lang?: string;
server_id?: string;
region_code?: string;
label?: string;
message?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
image?: string;
},
env?: Env,
telegramUserId?: string
): Promise<string> {
const { action } = args;
logger.info('시작', {
action,
userId: maskUserId(telegramUserId),
});
try {
const result = await executeServerAction(action, args, env, telegramUserId);
logger.info('완료', { result: result?.slice(0, 100) });
return result;
} catch (error) {
logger.error('서버 관리 오류', error as Error, { action });
return '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}