feat: 도메인 인라인 버튼 등록 + cheapest TLD + Cron 자동취소

- 도메인 등록 인라인 버튼 확인 플로우 (domain-register.ts)
- manage_domain에 cheapest action 추가 (가장 저렴한 TLD TOP 15)
- 24시간 경과 입금 대기 자동 취소 Cron (UTC 15:00)
- 거래 내역 한글 라벨 + description 표시
- CLAUDE.md 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-18 15:24:03 +09:00
parent 89f8ea19f1
commit db859efc56
8 changed files with 567 additions and 23 deletions

View File

@@ -77,7 +77,7 @@ export async function executeDepositFunction(
// 은행 알림이 이미 있으면 바로 확정 처리
const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description, confirmed_at)
VALUES (?, 'deposit', ?, 'confirmed', ?, '자동 매칭', CURRENT_TIMESTAMP)`
VALUES (?, 'deposit', ?, 'confirmed', ?, '입금', CURRENT_TIMESTAMP)`
).bind(userId, amount, depositor_name).run();
const txId = result.meta.last_row_id;
@@ -111,7 +111,7 @@ export async function executeDepositFunction(
// 은행 알림이 없으면 pending 거래 생성
const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description)
VALUES (?, 'deposit', ?, 'pending', ?, '사용자 입금 요청')`
VALUES (?, 'deposit', ?, 'pending', ?, '입금 대기')`
).bind(userId, amount, depositor_name).run();
return {
@@ -134,7 +134,7 @@ export async function executeDepositFunction(
const limit = funcArgs.limit || 10;
const transactions = await db.prepare(
`SELECT id, type, amount, status, depositor_name, created_at, confirmed_at
`SELECT id, type, amount, status, depositor_name, description, created_at, confirmed_at
FROM deposit_transactions
WHERE user_id = ?
ORDER BY created_at DESC
@@ -145,6 +145,7 @@ export async function executeDepositFunction(
amount: number;
status: string;
depositor_name: string;
description: string | null;
created_at: string;
confirmed_at: string | null;
}>();
@@ -160,6 +161,7 @@ export async function executeDepositFunction(
amount: tx.amount,
status: tx.status,
depositor_name: tx.depositor_name,
description: tx.description,
created_at: tx.created_at,
confirmed_at: tx.confirmed_at,
})),

137
src/domain-register.ts Normal file
View File

@@ -0,0 +1,137 @@
import { Env } from './types';
interface RegisterResult {
success: boolean;
domain?: string;
price?: number;
newBalance?: number;
nameservers?: string[];
expiresAt?: string;
error?: string;
}
// 도메인 등록 실행
export async function executeDomainRegister(
env: Env,
userId: number,
telegramUserId: string,
domain: string,
price: number
): Promise<RegisterResult> {
const apiKey = env.NAMECHEAP_API_KEY;
const apiUrl = 'https://namecheap-api.anvil.it.com';
if (!apiKey) {
return { success: false, error: 'API 키가 설정되지 않았습니다.' };
}
try {
// 1. 현재 잔액 확인
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 < price) {
return {
success: false,
error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${price.toLocaleString()}원)`
};
}
// 2. Namecheap API로 도메인 등록
console.log(`[DomainRegister] 도메인 등록 요청: ${domain}, 가격: ${price}`);
const registerResponse = await fetch(`${apiUrl}/domains/register`, {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
domain: domain,
years: 1,
telegram_id: telegramUserId,
}),
});
const registerResult = await registerResponse.json() as {
registered?: boolean;
domain?: string;
error?: string;
detail?: string;
};
if (!registerResponse.ok || !registerResult.registered) {
const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.';
console.error(`[DomainRegister] 등록 실패:`, registerResult);
return { success: false, error: errorMsg };
}
console.log(`[DomainRegister] 등록 성공:`, registerResult);
// 3. 잔액 차감 + 거래 기록 (트랜잭션)
await env.DB.batch([
env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
).bind(price, userId),
env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(userId, price, `도메인 등록: ${domain}`),
]);
// 4. user_domains 테이블에 추가
await env.DB.prepare(
'INSERT INTO user_domains (user_id, domain, verified, created_at) VALUES (?, ?, 1, datetime("now"))'
).bind(userId, domain).run();
// 5. 도메인 정보 조회 (네임서버 + 만료일)
let nameservers: string[] = [];
let expiresAt: string | undefined;
try {
// 도메인 정보에서 만료일 조회
const infoResponse = await fetch(`${apiUrl}/domains/${domain}/info`, {
headers: { 'X-API-Key': apiKey }
});
if (infoResponse.ok) {
const infoResult = await infoResponse.json() as { expires?: string };
if (infoResult.expires) {
// MM/DD/YYYY → YYYY-MM-DD 변환
const [month, day, year] = infoResult.expires.split('/');
expiresAt = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
}
// 네임서버 조회
const nsResponse = await fetch(`${apiUrl}/domains/${domain}/nameservers`, {
headers: { 'X-API-Key': apiKey }
});
if (nsResponse.ok) {
const nsResult = await nsResponse.json() as { nameservers?: string[] };
nameservers = nsResult.nameservers || [];
}
} catch (infoError) {
console.log(`[DomainRegister] 도메인 정보 조회 실패 (무시):`, infoError);
}
const newBalance = currentBalance - price;
console.log(`[DomainRegister] 완료: ${domain}, 잔액: ${currentBalance} -> ${newBalance}, 만료: ${expiresAt}, NS: ${nameservers.join(', ')}`);
return {
success: true,
domain: domain,
price: price,
newBalance: newBalance,
nameservers: nameservers,
expiresAt: expiresAt,
};
} catch (error) {
console.error(`[DomainRegister] 오류:`, error);
return {
success: false,
error: `도메인 등록 중 오류가 발생했습니다: ${String(error)}`
};
}
}

View File

@@ -1,6 +1,7 @@
import { Env, TelegramUpdate, EmailMessage, BankNotification } from './types';
import { validateWebhookRequest, checkRateLimit } from './security';
import { sendMessage, sendMessageWithKeyboard, setWebhook, getWebhookInfo, sendChatAction } from './telegram';
import { sendMessage, sendMessageWithKeyboard, setWebhook, getWebhookInfo, sendChatAction, answerCallbackQuery, editMessageText } from './telegram';
import { executeDomainRegister } from './domain-register';
import {
addToBuffer,
processAndSummarize,
@@ -111,9 +112,131 @@ async function handleMessage(
}
}
// 버튼 데이터 파싱
const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/);
if (keyboardMatch) {
const cleanText = responseText.replace(/__KEYBOARD__.+?__END__\n?/, '');
try {
const keyboardData = JSON.parse(keyboardMatch[1]);
if (keyboardData.type === 'domain_register') {
// 도메인 등록 확인 버튼
const callbackData = `domain_reg:${keyboardData.domain}:${keyboardData.price}`;
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, cleanText, [
[
{ text: '✅ 등록하기', callback_data: callbackData },
{ text: '❌ 취소', callback_data: 'domain_cancel' }
]
]);
return;
}
} catch (e) {
console.error('[Keyboard] 파싱 오류:', e);
}
}
await sendMessage(env.BOT_TOKEN, chatId, responseText);
}
// 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 user = await env.DB.prepare(
'SELECT id FROM users WHERE telegram_id = ?'
).bind(telegramUserId).first<{ id: number }>();
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 = parseInt(parts[2]);
// 처리 중 표시
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);
}
export default {
// HTTP 요청 핸들러
async fetch(request: Request, env: Env): Promise<Response> {
@@ -263,7 +386,7 @@ export default {
).bind(body.amount, user.id),
env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'deduct', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(user.id, body.amount, body.reason),
]);
@@ -353,7 +476,16 @@ export default {
}
try {
await handleMessage(env, validation.update!);
const update = validation.update!;
// Callback Query 처리 (인라인 버튼 클릭)
if (update.callback_query) {
await handleCallbackQuery(env, update.callback_query);
return new Response('OK');
}
// 일반 메시지 처리
await handleMessage(env, update);
return new Response('OK');
} catch (error) {
console.error('Message handling error:', error);
@@ -460,6 +592,60 @@ Documentation: https://github.com/your-repo
console.error('[Email] 처리 오류:', error);
}
},
// Cron Trigger: 만료된 입금 대기 자동 취소 (24시간)
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
console.log('[Cron] 만료된 입금 대기 정리 시작');
try {
// 24시간 이상 된 pending 거래 조회
const expiredTxs = await env.DB.prepare(
`SELECT dt.id, dt.amount, dt.depositor_name, u.telegram_id
FROM deposit_transactions dt
JOIN users u ON dt.user_id = u.id
WHERE dt.status = 'pending'
AND dt.type = 'deposit'
AND datetime(dt.created_at) < datetime('now', '-1 day')
LIMIT 100`
).all<{
id: number;
amount: number;
depositor_name: string;
telegram_id: string;
}>();
if (!expiredTxs.results?.length) {
console.log('[Cron] 만료된 거래 없음');
return;
}
console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`);
for (const tx of expiredTxs.results) {
// 상태를 cancelled로 변경
await env.DB.prepare(
"UPDATE deposit_transactions SET status = 'cancelled', description = '입금 대기 만료 (24시간)' WHERE id = ?"
).bind(tx.id).run();
// 사용자에게 알림
await sendMessage(
env.BOT_TOKEN,
parseInt(tx.telegram_id),
`⏰ <b>입금 대기 만료</b>\n\n` +
`입금자: ${tx.depositor_name}\n` +
`금액: ${tx.amount.toLocaleString()}\n\n` +
`24시간 이내에 입금이 확인되지 않아 자동 취소되었습니다.\n` +
`다시 입금하시려면 입금 후 알려주세요.`
);
console.log(`[Cron] 거래 #${tx.id} 만료 처리 완료`);
}
console.log('[Cron] 만료된 입금 대기 정리 완료');
} catch (error) {
console.error('[Cron] 오류:', error);
}
},
};
// Quoted-Printable UTF-8 디코딩

View File

@@ -128,8 +128,8 @@ const tools = [
properties: {
action: {
type: 'string',
enum: ['register', 'check', 'whois', 'list', 'info', 'get_ns', 'set_ns', 'price'],
description: 'price: TLD 가격 조회 (.com 가격, .io 가격), register: 도메인 등록, check: 가용성 확인, whois: WHOIS 조회, list: 내 도메인 목록, info: 도메인 상세정보, get_ns/set_ns: 네임서버 조회/변경',
enum: ['register', 'check', 'whois', 'list', 'info', 'get_ns', 'set_ns', 'price', 'cheapest'],
description: 'price: TLD 가격 조회 (.com 가격, .io 가격), cheapest: 가장 저렴한 TLD 목록 조회, register: 도메인 등록, check: 가용성 확인, whois: WHOIS 조회, list: 내 도메인 목록, info: 도메인 상세정보, get_ns/set_ns: 네임서버 조회/변경',
},
domain: {
type: 'string',
@@ -523,6 +523,11 @@ async function callNamecheapApi(
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
}
case 'get_all_prices': {
return fetch(`${apiUrl}/prices`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
}
case 'check_domains': {
return fetch(`${apiUrl}/domains/check`, {
method: 'POST',
@@ -761,6 +766,27 @@ async function executeDomainAction(
return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${price?.toLocaleString()}원/년`;
}
case 'cheapest': {
const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, telegramUserId, db, userId);
if (result.error) return `🚫 ${result.error}`;
// 가격 > 0인 TLD만 필터링, krw 기준 정렬
const sorted = (result as any[])
.filter((p: any) => p.krw > 0)
.sort((a: any, b: any) => a.krw - b.krw)
.slice(0, 15);
if (sorted.length === 0) {
return '🚫 TLD 가격 정보를 가져올 수 없습니다.';
}
const list = sorted.map((p: any, i: number) =>
`${i + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`
).join('\n');
return `💰 가장 저렴한 TLD TOP 15\n\n${list}\n\n💡 특정 TLD 가격은 ".com 가격" 형식으로 조회`;
}
case 'register': {
if (!domain) return '🚫 등록할 도메인을 지정해주세요.';
if (!telegramUserId) return '🚫 도메인 등록에는 로그인이 필요합니다.';
@@ -783,33 +809,38 @@ async function executeDomainAction(
balance = balanceRow?.balance || 0;
}
// 4. 확인 페이지 생성 (코드에서 고정 형식)
// 4. 확인 페이지 생성 (인라인 버튼 포함)
if (balance >= price) {
return `📋 도메인 등록 확인
// 버튼 데이터를 특수 마커로 포함
const keyboardData = JSON.stringify({
type: 'domain_register',
domain: domain,
price: price
});
return `__KEYBOARD__${keyboardData}__END__
📋 <b>도메인 등록 확인</b>
• 도메인: ${domain}
• 도메인: <code>${domain}</code>
• 가격: ${price.toLocaleString()}원 (예치금에서 차감)
• 현재 잔액: ${balance.toLocaleString()}
• 현재 잔액: ${balance.toLocaleString()}
• 등록 기간: 1년
📌 등록자 정보
📌 <b>등록자 정보</b>
서비스 기본 정보로 등록됩니다.
(WHOIS Guard가 적용되어 개인정보는 비공개)
⚠️ 주의사항
도메인 등록 후에는 취소 및 환불이 불가능합니다.
등록을 진행하시려면 '확인'이라고 입력해주세요.`;
⚠️ <b>주의사항</b>
도메인 등록 후에는 취소 및 환불이 불가능합니다.`;
} else {
const shortage = price - balance;
return `📋 도메인 등록 확인
return `📋 <b>도메인 등록 확인</b>
• 도메인: ${domain}
• 도메인: <code>${domain}</code>
• 가격: ${price.toLocaleString()}
• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족
• 부족 금액: ${shortage.toLocaleString()}
💳 입금 계좌
💳 <b>입금 계좌</b>
하나은행 427-910018-27104 (주식회사 아이언클래드)
입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`;
}
@@ -866,10 +897,12 @@ ${result.account_info.bank} ${result.account_info.account}
return `📋 ${result.message}`;
}
const statusIcon = (s: string) => s === 'confirmed' ? '✓' : s === 'pending' ? '⏳' : '✗';
const typeLabel = (t: string) => t === 'deposit' ? '입금' : t === 'withdrawal' ? '출금' : t === 'refund' ? '환불' : t;
const txList = result.transactions.map((tx: any) => {
const date = tx.confirmed_at || tx.created_at;
const dateStr = date ? new Date(date).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }) : '';
return `#${tx.id}: ${tx.type === 'deposit' ? '입금' : tx.type} ${tx.amount.toLocaleString()}${statusIcon(tx.status)} (${dateStr})`;
const desc = tx.description ? ` - ${tx.description}` : '';
return `#${tx.id}: ${typeLabel(tx.type)} ${tx.amount.toLocaleString()}${statusIcon(tx.status)} (${dateStr})${desc}`;
}).join('\n');
return `📋 거래 내역\n\n${txList}`;
}
@@ -1266,6 +1299,12 @@ export async function generateOpenAIResponse(
for (const toolCall of assistantMessage.tool_calls) {
const args = JSON.parse(toolCall.function.arguments);
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
// __KEYBOARD__ 마커가 있으면 AI 재해석 없이 바로 반환 (버튼 보존)
if (result.includes('__KEYBOARD__')) {
return result;
}
toolResults.push({
role: 'tool',
tool_call_id: toolCall.id,

View File

@@ -52,7 +52,7 @@ export async function setWebhook(
body: JSON.stringify({
url: webhookUrl,
secret_token: secretToken,
allowed_updates: ['message'],
allowed_updates: ['message', 'callback_query'],
drop_pending_updates: true,
}),
}
@@ -153,3 +153,63 @@ export async function sendChatAction(
return false;
}
}
// Callback Query 응답 (버튼 클릭 알림)
export async function answerCallbackQuery(
token: string,
callbackQueryId: string,
options?: {
text?: string;
show_alert?: boolean;
}
): Promise<boolean> {
try {
const response = await fetch(
`https://api.telegram.org/bot${token}/answerCallbackQuery`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
callback_query_id: callbackQueryId,
text: options?.text,
show_alert: options?.show_alert,
}),
}
);
return response.ok;
} catch {
return false;
}
}
// 메시지 수정 (인라인 키보드 제거/변경용)
export async function editMessageText(
token: string,
chatId: number,
messageId: number,
text: string,
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_markup?: { inline_keyboard: InlineKeyboardButton[][] };
}
): Promise<boolean> {
try {
const response = await fetch(
`https://api.telegram.org/bot${token}/editMessageText`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
message_id: messageId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_markup: options?.reply_markup,
}),
}
);
return response.ok;
} catch {
return false;
}
}

View File

@@ -28,6 +28,15 @@ export interface N8nResponse {
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
callback_query?: CallbackQuery;
}
export interface CallbackQuery {
id: string;
from: TelegramUser;
message?: TelegramMessage;
chat_instance: string;
data?: string;
}
export interface TelegramMessage {