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:
109
CLAUDE.md
109
CLAUDE.md
@@ -477,6 +477,63 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
|
||||
| `confirm_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
|
||||
@@ -493,7 +550,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
|
||||
**manage_domain 도구 파라미터:**
|
||||
```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, // 대상 도메인
|
||||
nameservers?: string[], // set_ns용
|
||||
tld?: string // price용
|
||||
@@ -510,6 +567,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
|
||||
| `check` | 가용성 확인 + 가격 | 공개 |
|
||||
| `whois` | WHOIS 조회 | 공개 |
|
||||
| `price` | TLD 가격 | 공개 |
|
||||
| `cheapest` | 가장 저렴한 TLD 목록 (TOP 15) | 공개 |
|
||||
| `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`)
|
||||
|
||||
**별도 구현된 코드 레벨 도구**
|
||||
@@ -568,6 +661,20 @@ executeDomainAction():
|
||||
- 가격 정책: Namecheap 원가 + 13%, 매일 환율 업데이트
|
||||
- 권한 체크: `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 자동 적용 (개인정보 비공개)
|
||||
|
||||
@@ -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
137
src/domain-register.ts
Normal 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)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
192
src/index.ts
192
src/index.ts
@@ -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 디코딩
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,10 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
||||
# 1. Email > Email Routing > Routes
|
||||
# 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 으로 설정):
|
||||
# - BOT_TOKEN: Telegram Bot Token
|
||||
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
|
||||
|
||||
Reference in New Issue
Block a user