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

109
CLAUDE.md
View File

@@ -477,6 +477,63 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
| `confirm_deposit` | 입금 확인 | 관리자 | | `confirm_deposit` | 입금 확인 | 관리자 |
| `reject_deposit` | 입금 거절 | 관리자 | | `reject_deposit` | 입금 거절 | 관리자 |
### Cron 자동 취소 (24시간)
**목적:** 24시간 이상 대기 중인 입금 요청 자동 취소 + 사용자 알림
```
wrangler.toml:
crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00 (매일 자정)
index.ts (scheduled 핸들러):
1. pending + created_at > 24시간 거래 조회
2. status → cancelled 업데이트
3. 사용자에게 Telegram 알림 전송
```
**wrangler.toml 설정:**
```toml
[triggers]
crons = ["0 15 * * *"] # KST 00:00
```
**사용자 알림 메시지:**
```
⏰ 입금 대기 자동 취소
거래 #123이 24시간 내 확인되지 않아 자동 취소되었습니다.
• 입금액: 10,000원
• 입금자: 홍길동
실제 입금하셨다면 다시 신고해주세요.
```
### 거래 내역 표시 형식
**응답 포맷 (`formatDepositResult`):**
```
#5: 입금 10,000원 ✓ (01/17)
#4: 출금 5,000원 ✓ (01/15) - 도메인 등록: example.com
#3: 입금 20,000원 ⏳ (01/14)
#2: 입금 5,000원 ✗ (01/10)
```
**상태 아이콘:**
| 상태 | 아이콘 | 설명 |
|------|--------|------|
| `confirmed` | ✓ | 확인 완료 |
| `pending` | ⏳ | 대기 중 |
| `cancelled` / `rejected` | ✗ | 취소/거절 |
**타입 라벨:**
| DB 값 | 표시 |
|-------|------|
| `deposit` | 입금 |
| `withdrawal` | 출금 |
| `refund` | 환불 |
**description 필드:** 거래 사유 (예: "도메인 등록: example.com")
--- ---
## Domain System ## Domain System
@@ -493,7 +550,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
**manage_domain 도구 파라미터:** **manage_domain 도구 파라미터:**
```typescript ```typescript
{ {
action: 'register' | 'check' | 'whois' | 'list' | 'info' | 'get_ns' | 'set_ns' | 'price', action: 'register' | 'check' | 'whois' | 'list' | 'info' | 'get_ns' | 'set_ns' | 'price' | 'cheapest',
domain?: string, // 대상 도메인 domain?: string, // 대상 도메인
nameservers?: string[], // set_ns용 nameservers?: string[], // set_ns용
tld?: string // price용 tld?: string // price용
@@ -510,6 +567,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
| `check` | 가용성 확인 + 가격 | 공개 | | `check` | 가용성 확인 + 가격 | 공개 |
| `whois` | WHOIS 조회 | 공개 | | `whois` | WHOIS 조회 | 공개 |
| `price` | TLD 가격 | 공개 | | `price` | TLD 가격 | 공개 |
| `cheapest` | 가장 저렴한 TLD 목록 (TOP 15) | 공개 |
| `register` | 등록 확인 페이지 | 사용자 | | `register` | 등록 확인 페이지 | 사용자 |
### 도메인 등록 흐름 ### 도메인 등록 흐름
@@ -540,6 +598,41 @@ executeDomainAction():
└─────────────────────┴─────────────────────┘ └─────────────────────┴─────────────────────┘
``` ```
### 인라인 버튼 확인 플로우 (Callback Query)
**목적:** 사용자에게 "확인/취소" 버튼 표시 후 클릭으로 등록 진행
```
executeDomainAction(register):
1. __KEYBOARD__{type, domain, price}__END__ 마커 포함 응답 생성
telegram.ts (sendMessage):
2. __KEYBOARD__ 감지 → 마커 파싱 → inline_keyboard 생성
Telegram:
3. 사용자에게 "✅ 등록 확인 / ❌ 취소" 버튼 표시
index.ts (callback_query 핸들러):
4. 버튼 클릭 감지 → data 파싱 → domain-register.ts 호출
domain-register.ts:
5. 잔액 재확인 → 실제 등록 API 호출 → 결과 반환
```
**관련 코드:**
| 파일 | 역할 |
|------|------|
| `openai-service.ts:786-807` | `__KEYBOARD__` 마커 생성 |
| `telegram.ts:sendMessage()` | 마커 파싱 → inline_keyboard 변환 |
| `index.ts:callback_query` | 버튼 클릭 핸들링 |
| `domain-register.ts` | 실제 도메인 등록 실행 |
**버튼 콜백 데이터 형식:**
```typescript
// 확인: confirm_domain_register:example.com:15000
// 취소: cancel_domain_register:example.com
```
### 도메인 추천 기능 (`suggest_domains`) ### 도메인 추천 기능 (`suggest_domains`)
**별도 구현된 코드 레벨 도구** **별도 구현된 코드 레벨 도구**
@@ -568,6 +661,20 @@ executeDomainAction():
- 가격 정책: Namecheap 원가 + 13%, 매일 환율 업데이트 - 가격 정책: Namecheap 원가 + 13%, 매일 환율 업데이트
- 권한 체크: `user_domains` 테이블 `verified=1` - 권한 체크: `user_domains` 테이블 `verified=1`
**Production/Sandbox 전환:**
```bash
# namecheap-api 서버의 .env 파일
NAMECHEAP_API_USER=your_username
NAMECHEAP_SANDBOX=false # true: 테스트 모드, false: 실제 등록
```
| 환경 | NAMECHEAP_SANDBOX | API 엔드포인트 |
|------|-------------------|----------------|
| Production | `false` | api.namecheap.com |
| Sandbox | `true` | api.sandbox.namecheap.com |
**⚠️ 주의:** Sandbox에서 등록한 도메인은 실제로 등록되지 않음
**등록자 정보:** **등록자 정보:**
- 현재: 서비스 기본 정보만 지원 (일본 주소) - 현재: 서비스 기본 정보만 지원 (일본 주소)
- WHOIS Guard 자동 적용 (개인정보 비공개) - WHOIS Guard 자동 적용 (개인정보 비공개)

View File

@@ -77,7 +77,7 @@ export async function executeDepositFunction(
// 은행 알림이 이미 있으면 바로 확정 처리 // 은행 알림이 이미 있으면 바로 확정 처리
const result = await db.prepare( const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description, confirmed_at) `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(); ).bind(userId, amount, depositor_name).run();
const txId = result.meta.last_row_id; const txId = result.meta.last_row_id;
@@ -111,7 +111,7 @@ export async function executeDepositFunction(
// 은행 알림이 없으면 pending 거래 생성 // 은행 알림이 없으면 pending 거래 생성
const result = await db.prepare( const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description) `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description)
VALUES (?, 'deposit', ?, 'pending', ?, '사용자 입금 요청')` VALUES (?, 'deposit', ?, 'pending', ?, '입금 대기')`
).bind(userId, amount, depositor_name).run(); ).bind(userId, amount, depositor_name).run();
return { return {
@@ -134,7 +134,7 @@ export async function executeDepositFunction(
const limit = funcArgs.limit || 10; const limit = funcArgs.limit || 10;
const transactions = await db.prepare( 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 FROM deposit_transactions
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -145,6 +145,7 @@ export async function executeDepositFunction(
amount: number; amount: number;
status: string; status: string;
depositor_name: string; depositor_name: string;
description: string | null;
created_at: string; created_at: string;
confirmed_at: string | null; confirmed_at: string | null;
}>(); }>();
@@ -160,6 +161,7 @@ export async function executeDepositFunction(
amount: tx.amount, amount: tx.amount,
status: tx.status, status: tx.status,
depositor_name: tx.depositor_name, depositor_name: tx.depositor_name,
description: tx.description,
created_at: tx.created_at, created_at: tx.created_at,
confirmed_at: tx.confirmed_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 { Env, TelegramUpdate, EmailMessage, BankNotification } from './types';
import { validateWebhookRequest, checkRateLimit } from './security'; 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 { import {
addToBuffer, addToBuffer,
processAndSummarize, 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); 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 { export default {
// HTTP 요청 핸들러 // HTTP 요청 핸들러
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
@@ -263,7 +386,7 @@ export default {
).bind(body.amount, user.id), ).bind(body.amount, user.id),
env.DB.prepare( env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) `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), ).bind(user.id, body.amount, body.reason),
]); ]);
@@ -353,7 +476,16 @@ export default {
} }
try { 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'); return new Response('OK');
} catch (error) { } catch (error) {
console.error('Message handling error:', error); console.error('Message handling error:', error);
@@ -460,6 +592,60 @@ Documentation: https://github.com/your-repo
console.error('[Email] 처리 오류:', error); 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 디코딩 // Quoted-Printable UTF-8 디코딩

View File

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

View File

@@ -52,7 +52,7 @@ export async function setWebhook(
body: JSON.stringify({ body: JSON.stringify({
url: webhookUrl, url: webhookUrl,
secret_token: secretToken, secret_token: secretToken,
allowed_updates: ['message'], allowed_updates: ['message', 'callback_query'],
drop_pending_updates: true, drop_pending_updates: true,
}), }),
} }
@@ -153,3 +153,63 @@ export async function sendChatAction(
return false; 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 { export interface TelegramUpdate {
update_id: number; update_id: number;
message?: TelegramMessage; message?: TelegramMessage;
callback_query?: CallbackQuery;
}
export interface CallbackQuery {
id: string;
from: TelegramUser;
message?: TelegramMessage;
chat_instance: string;
data?: string;
} }
export interface TelegramMessage { export interface TelegramMessage {

View File

@@ -22,6 +22,10 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
# 1. Email > Email Routing > Routes # 1. Email > Email Routing > Routes
# 2. deposit@your-domain.com → Worker: telegram-summary-bot # 2. deposit@your-domain.com → Worker: telegram-summary-bot
# Cron Trigger: 매일 자정(KST) 실행 - 24시간 경과된 입금 대기 자동 취소
[triggers]
crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00
# Secrets (wrangler secret put 으로 설정): # Secrets (wrangler secret put 으로 설정):
# - BOT_TOKEN: Telegram Bot Token # - BOT_TOKEN: Telegram Bot Token
# - WEBHOOK_SECRET: Webhook 검증용 시크릿 # - WEBHOOK_SECRET: Webhook 검증용 시크릿