From db859efc56cd56220f364af1bd77e80e7e639b4c Mon Sep 17 00:00:00 2001 From: kappa Date: Sun, 18 Jan 2026 15:24:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?+=20cheapest=20TLD=20+=20Cron=20=EC=9E=90=EB=8F=99=EC=B7=A8?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 등록 인라인 버튼 확인 플로우 (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 --- CLAUDE.md | 109 ++++++++++++++++++++++- src/deposit-agent.ts | 8 +- src/domain-register.ts | 137 +++++++++++++++++++++++++++++ src/index.ts | 192 ++++++++++++++++++++++++++++++++++++++++- src/openai-service.ts | 69 +++++++++++---- src/telegram.ts | 62 ++++++++++++- src/types.ts | 9 ++ wrangler.toml | 4 + 8 files changed, 567 insertions(+), 23 deletions(-) create mode 100644 src/domain-register.ts diff --git a/CLAUDE.md b/CLAUDE.md index 056ca56..9ce38ae 100644 --- a/CLAUDE.md +++ b/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 자동 적용 (개인정보 비공개) diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts index 07e6adf..daa21c7 100644 --- a/src/deposit-agent.ts +++ b/src/deposit-agent.ts @@ -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, })), diff --git a/src/domain-register.ts b/src/domain-register.ts new file mode 100644 index 0000000..ce31bc4 --- /dev/null +++ b/src/domain-register.ts @@ -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 { + 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)}` + }; + } +} diff --git a/src/index.ts b/src/index.ts index cc442ce..1ebcba1 100644 --- a/src/index.ts +++ b/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 { + 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, + `⏳ ${domain} 등록 처리 중...` + ); + + // 도메인 등록 실행 + 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🌐 현재 네임서버:\n${result.nameservers.map(ns => `• ${ns}`).join('\n')}` + : ''; + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `✅ 도메인 등록 완료! + +• 도메인: ${result.domain} +• 결제 금액: ${result.price?.toLocaleString()}원 +• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo} + +🎉 축하합니다! 도메인이 성공적으로 등록되었습니다. +네임서버 변경이 필요하면 말씀해주세요.` + ); + } else { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 등록 실패 + +${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 { @@ -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 { + 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), + `⏰ 입금 대기 만료\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 디코딩 diff --git a/src/openai-service.ts b/src/openai-service.ts index 457befd..e06e2c3 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -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__ +📋 도메인 등록 확인 -• 도메인: ${domain} +• 도메인: ${domain} • 가격: ${price.toLocaleString()}원 (예치금에서 차감) -• 현재 잔액: ${balance.toLocaleString()}원 ✓ +• 현재 잔액: ${balance.toLocaleString()}원 ✅ • 등록 기간: 1년 -📌 등록자 정보 +📌 등록자 정보 서비스 기본 정보로 등록됩니다. (WHOIS Guard가 적용되어 개인정보는 비공개) -⚠️ 주의사항 -도메인 등록 후에는 취소 및 환불이 불가능합니다. - -등록을 진행하시려면 '확인'이라고 입력해주세요.`; +⚠️ 주의사항 +도메인 등록 후에는 취소 및 환불이 불가능합니다.`; } else { const shortage = price - balance; - return `📋 도메인 등록 확인 + return `📋 도메인 등록 확인 -• 도메인: ${domain} +• 도메인: ${domain} • 가격: ${price.toLocaleString()}원 • 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족 • 부족 금액: ${shortage.toLocaleString()}원 -💳 입금 계좌 +💳 입금 계좌 하나은행 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, diff --git a/src/telegram.ts b/src/telegram.ts index 55db0a5..709480a 100644 --- a/src/telegram.ts +++ b/src/telegram.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/types.ts b/src/types.ts index dd5aac6..f7b19d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/wrangler.toml b/wrangler.toml index c5b1988..ecea597 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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 검증용 시크릿