Files
telegram-bot-workers/src/routes/handlers/callback-handler.ts
kappa 5ba555864a feat: add server provisioning system with Queue
- Add server-provision.ts for async server creation
- Add SERVER_PROVISION_QUEUE with DLQ for reliability
- Add cron job for auto-cleanup of pending orders (5min)
- Add server delete confirmation with inline keyboard
- Update types for server orders, images, and provisioning
- Add server tables to schema (server_orders, server_instances)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:26:17 +09:00

307 lines
8.9 KiB
TypeScript

import { answerCallbackQuery, editMessageText } from '../../telegram';
import { UserService } from '../../services/user-service';
import { executeDomainRegister } from '../../domain-register';
import type { Env, TelegramUpdate } from '../../types';
/**
* 도메인 형식 검증 정규식
* - 최소 2글자 이상
* - 숫자/문자로 시작, 숫자/문자로 끝
* - 중간에 하이픈, 점 허용
* - TLD 2글자 이상
*/
const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,251}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/;
/**
* 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 priceStr = parts[2];
// 도메인 형식 검증
if (!domain || domain.length > 253 || !DOMAIN_REGEX.test(domain)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 도메인 형식입니다.' });
return;
}
const price = parseInt(priceStr, 10);
if (isNaN(price) || price < 0 || price > 10000000) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' });
return;
}
// SECURITY NOTE: Price from callback_data is range-validated here (0-10M)
// but real-time price verification happens in executeDomainRegister()
// which fetches current price from Namecheap API before charging
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('server_order:')) {
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const callbackUserId = parts[1];
const index = parseInt(parts[2], 10);
// SECURITY: Verify callback userId matches the actual user
if (callbackUserId !== telegramUserId) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, {
text: '⚠️ 권한이 없습니다.',
show_alert: true
});
return;
}
if (isNaN(index) || index < 0 || index > 2) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '처리 중...' });
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'⏳ 서버 주문 처리 중...'
);
// 세션 조회
const { getServerSession, deleteServerSession } = await import('../../server-agent');
if (!env.DB) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'❌ 세션 저장소가 설정되지 않았습니다.'
);
return;
}
// Use verified telegramUserId instead of callback userId
const session = await getServerSession(env.DB, telegramUserId);
if (!session || !session.lastRecommendation) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.'
);
return;
}
const selected = session.lastRecommendation.recommendations[index];
if (!selected) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'❌ 선택한 서버를 찾을 수 없습니다.'
);
await deleteServerSession(env.DB, telegramUserId);
return;
}
// 잔액 확인
const deposit = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ balance: number }>();
const price = selected.price?.monthly_krw || 0;
if (!deposit || deposit.balance < price) {
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`❌ 잔액이 부족합니다.
• 서버 가격: ${price.toLocaleString()}원/월
• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}
• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}
잔액을 충전 후 다시 시도해주세요.`
);
return;
}
// Queue 확인
if (!env.SERVER_PROVISION_QUEUE) {
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
'❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.'
);
return;
}
// 주문 생성 (DB INSERT)
const { createServerOrder, sendProvisionMessage } = await import('../../server-provision');
const orderId = await createServerOrder(
env.DB,
user.id,
telegramUserId,
selected.pricing_id,
selected.region.code,
'anvil',
price,
`${selected.plan_name} - ${session.collectedInfo?.useCase || 'server'}`
);
// Queue에 메시지 전송
await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, user.id, telegramUserId);
// 즉시 응답
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`📋 <b>서버 주문 접수 완료!</b> (주문 #${orderId})
• 서버: ${selected.plan_name}
• 리전: ${selected.region.name} (${selected.region.code})
• 가격: ${price.toLocaleString()}원/월
⏳ 서버를 생성하고 있습니다... (1-2분 소요)
완료되면 메시지로 알려드릴게요.`
);
// 세션 삭제
await deleteServerSession(env.DB, telegramUserId);
return;
}
// 서버 주문 취소
if (data.startsWith('server_cancel:')) {
const parts = data.split(':');
if (parts.length !== 2) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const callbackUserId = parts[1];
// SECURITY: Verify callback userId matches the actual user
if (callbackUserId !== telegramUserId) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, {
text: '⚠️ 권한이 없습니다.',
show_alert: true
});
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'❌ 서버 신청이 취소되었습니다.'
);
// 세션 삭제
const { deleteServerSession } = await import('../../server-agent');
if (env.DB) {
await deleteServerSession(env.DB, telegramUserId);
}
return;
}
// Note: server_delete callback handler removed - now using text-based confirmation
// 알 수 없는 callback data
await answerCallbackQuery(env.BOT_TOKEN, queryId);
}