import type { Env, NamecheapPriceResponse, NamecheapDomainListItem, NamecheapCheckResult, OpenAIResponse } from '../types'; import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger, maskUserId } from '../utils/logger'; import { getOpenAIUrl } from '../utils/api-urls'; import { ERROR_MESSAGES } from '../constants/messages'; import { MESSAGE_MARKERS } from '../constants'; const logger = createLogger('domain-tool'); // Helper to safely get string value from Record function getStringValue(obj: Record, key: string): string | undefined { const value = obj[key]; return typeof value === 'string' ? value : undefined; } // Helper to safely get number value from Record function getNumberValue(obj: Record, key: string): number | undefined { const value = obj[key]; return typeof value === 'number' ? value : undefined; } // Helper to safely get array value from Record function getArrayValue(obj: Record, key: string): T[] | undefined { const value = obj[key]; return Array.isArray(value) ? value as T[] : undefined; } // Type guard to check if result is an error function isErrorResult(result: unknown): result is { error: string } { return typeof result === 'object' && result !== null && 'error' in result; } // Type guard to check if result is NamecheapPriceResponse function isNamecheapPriceResponse(result: unknown): result is NamecheapPriceResponse { return typeof result === 'object' && result !== null && 'krw' in result; } // Namecheap 날짜 형식 변환 (MM/DD/YYYY → YYYY-MM-DD) function convertNamecheapDate(date: string): string { const [month, day, year] = date.split('/'); return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; } // 네임서버 형식 검증 function validateNameservers(nameservers: string[]): string | null { if (!nameservers || nameservers.length < 2) { return '❌ 최소 2개의 네임서버가 필요합니다.'; } const nsPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; for (const ns of nameservers) { if (!ns || !ns.trim()) { return '❌ 빈 네임서버가 있습니다.'; } if (!nsPattern.test(ns.trim())) { return `❌ 잘못된 네임서버 형식: ${ns}`; } } return null; // valid } // KV 캐싱 인터페이스 interface CachedTLDPrice { tld: string; krw: number; usd?: number; cached_at: string; } // 단일 TLD 가격 캐시 조회 async function getCachedTLDPrice( kv: KVNamespace, tld: string ): Promise { try { const key = `tld_price:${tld}`; const cached = await kv.get(key, 'json'); if (cached) { logger.info('TLDCache HIT', { tld }); return cached as CachedTLDPrice; } logger.info('TLDCache MISS', { tld }); return null; } catch (error) { logger.error('TLDCache KV 조회 오류', error as Error, { tld }); return null; } } // 단일 TLD 가격 캐시 저장 async function setCachedTLDPrice( kv: KVNamespace, tld: string, price: NamecheapPriceResponse ): Promise { try { const key = `tld_price:${tld}`; const krwPrice = price.krw ?? price.register_krw ?? 0; const data: CachedTLDPrice = { tld, krw: krwPrice, usd: price.usd, cached_at: new Date().toISOString(), }; await kv.put(key, JSON.stringify(data), { expirationTtl: 3600, // 1시간 }); logger.info('TLDCache SET', { tld, krw: data.krw }); } catch (error) { logger.error('TLDCache KV 저장 오류', error as Error, { tld }); } } // 전체 TLD 가격 캐시 조회 async function getCachedAllPrices( kv: KVNamespace ): Promise { try { const key = 'tld_price:all'; const cached = await kv.get(key, 'json'); if (cached) { logger.info('TLDCache HIT: all prices'); return cached as NamecheapPriceResponse[]; } logger.info('TLDCache MISS: all prices'); return null; } catch (error) { logger.error('TLDCache KV 조회 오류', error as Error, { key: 'all' }); return null; } } // 전체 TLD 가격 캐시 저장 async function setCachedAllPrices( kv: KVNamespace, prices: NamecheapPriceResponse[] ): Promise { try { const key = 'tld_price:all'; await kv.put(key, JSON.stringify(prices), { expirationTtl: 3600, // 1시간 }); logger.info('TLDCache SET: all prices', { count: prices.length }); } catch (error) { logger.error('TLDCache KV 저장 오류', error as Error, { key: 'all' }); } } export const manageDomainTool = { type: 'function', function: { name: 'manage_domain', description: '도메인 관리 및 정보 조회. ".com 가격", ".io 가격" 같은 TLD 가격 조회, 도메인 등록, WHOIS 조회, 네임서버 관리 등을 처리합니다.', parameters: { type: 'object', properties: { action: { type: 'string', 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: 도메인 상세정보(내 도메인이 아니면 WHOIS 조회), get_ns/set_ns: 네임서버 조회/변경', }, domain: { type: 'string', description: '대상 도메인 또는 TLD (예: example.com, .com, com). price action에서는 TLD만 전달 가능', }, nameservers: { type: 'array', items: { type: 'string' }, description: '설정할 네임서버 목록. set_ns action에만 필요 (예: ["ns1.example.com", "ns2.example.com"])', }, tld: { type: 'string', description: 'TLD. price action에서 사용 (예: tld="com" 또는 domain=".com" 또는 domain="com" 모두 가능)', }, }, required: ['action'], }, }, }; export const suggestDomainsTool = { type: 'function', function: { name: 'suggest_domains', description: '키워드나 비즈니스 설명을 기반으로 도메인 이름을 추천합니다. 창의적인 도메인 아이디어를 생성하고 가용성을 확인하여 등록 가능한 도메인만 가격과 함께 제안합니다. "도메인 추천", "도메인 제안", "도메인 아이디어" 등의 요청에 사용하세요.', parameters: { type: 'object', properties: { keywords: { type: 'string', description: '도메인 추천을 위한 키워드나 비즈니스 설명 (예: 커피숍, IT 스타트업, 서버 호스팅)', }, }, required: ['keywords'], }, }, }; // Namecheap API 호출 (allowedDomains로 필터링) async function callNamecheapApi( funcName: string, funcArgs: Record, allowedDomains: string[], env?: Env, telegramUserId?: string, db?: D1Database, userId?: number ): Promise { if (!env?.NAMECHEAP_API_KEY_INTERNAL) { return { error: 'Namecheap API 키가 설정되지 않았습니다.' }; } const apiKey = env.NAMECHEAP_API_KEY_INTERNAL; const apiUrl = env.NAMECHEAP_API_URL || 'https://namecheap.api.inouter.com'; // 도메인 권한 체크 (쓰기 작업만) // 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능 if (['set_nameservers', 'create_child_ns', 'delete_child_ns'].includes(funcName)) { const domain = funcArgs.domain; if (typeof domain === 'string' && !allowedDomains.includes(domain)) { return { error: `권한 없음: ${domain}은 관리할 수 없는 도메인입니다.` }; } } switch (funcName) { case 'list_domains': { const page = getNumberValue(funcArgs, 'page') || 1; const pageSize = getNumberValue(funcArgs, 'page_size') || 100; const result = await retryWithBackoff( () => fetch(`${apiUrl}/domains?page=${page}&page_size=${pageSize}`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()), { maxRetries: 3 } ) as NamecheapDomainListItem[]; // 허용된 도메인만 필터링, 날짜는 ISO 형식으로 변환 return result .filter((d: NamecheapDomainListItem) => allowedDomains.includes(d.name)) .map((d: NamecheapDomainListItem) => ({ ...d, created: convertNamecheapDate(d.created), expires: convertNamecheapDate(d.expires), user: undefined, // 민감 정보 제거 })); } case 'get_domain_info': { const domain = getStringValue(funcArgs, 'domain'); // 목록 API에서 더 많은 정보 조회 (단일 API는 정보 부족) const domains = await retryWithBackoff( () => fetch(`${apiUrl}/domains?page=1&page_size=100`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()), { maxRetries: 3 } ) as NamecheapDomainListItem[]; const domainInfo = domains.find((d: NamecheapDomainListItem) => d.name === domain); if (!domainInfo) { return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` }; } // 민감 정보 필터링 (user/owner 제거), 날짜는 ISO 형식으로 변환 return { domain: domainInfo.name, created: convertNamecheapDate(domainInfo.created), expires: convertNamecheapDate(domainInfo.expires), is_expired: domainInfo.is_expired, auto_renew: domainInfo.auto_renew, is_locked: domainInfo.is_locked, whois_guard: domainInfo.whois_guard, }; } case 'get_nameservers': { const domain = getStringValue(funcArgs, 'domain'); return retryWithBackoff( () => fetch(`${apiUrl}/dns/${domain}/nameservers`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()), { maxRetries: 3 } ); } case 'set_nameservers': { const domain = getStringValue(funcArgs, 'domain'); const nameservers = getArrayValue(funcArgs, 'nameservers'); const res = await fetch(`${apiUrl}/dns/${domain}/nameservers`, { method: 'PUT', headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, nameservers }), }); const text = await res.text(); if (!res.ok) { // Namecheap 에러 메시지 파싱 if (text.includes('subordinate hosts') || text.includes('Non existen')) { const nsArray = Array.isArray(nameservers) ? nameservers : []; return { error: `네임서버 변경 실패: ${nsArray.join(', ')}는 등록되지 않은 네임서버입니다. 자기 도메인을 네임서버로 사용하려면 먼저 Namecheap에서 Child Nameserver(글루 레코드)를 IP 주소와 함께 등록해야 합니다.` }; } return { error: `네임서버 변경 실패: ${text}` }; } try { return JSON.parse(text); } catch { return { success: true, message: text }; } } case 'create_child_ns': { const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ nameserver: funcArgs.nameserver, ip: funcArgs.ip }), }); const data = await res.json() as { detail?: string }; if (!res.ok) { return { error: data.detail || `Child NS 생성 실패` }; } return data; } case 'get_child_ns': { const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns/${funcArgs.nameserver}`, { headers: { 'X-API-Key': apiKey }, }); const data = await res.json() as { detail?: string }; if (!res.ok) { return { error: data.detail || `Child NS 조회 실패` }; } return data; } case 'delete_child_ns': { const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns/${funcArgs.nameserver}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey }, }); const data = await res.json() as { detail?: string }; if (!res.ok) { return { error: data.detail || `Child NS 삭제 실패` }; } return data; } case 'get_balance': return retryWithBackoff( () => fetch(`${apiUrl}/account/balance`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()), { maxRetries: 3 } ); case 'get_price': { const tldRaw = getStringValue(funcArgs, 'tld'); const tld = tldRaw?.replace(/^\./, ''); // .com → com return retryWithBackoff( () => fetch(`${apiUrl}/prices/${tld}`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()), { maxRetries: 3 } ); } case 'get_all_prices': { return retryWithBackoff( () => fetch(`${apiUrl}/prices`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()), { maxRetries: 3 } ); } case 'check_domains': { const domains = getArrayValue(funcArgs, 'domains'); // POST but idempotent (read-only check) return retryWithBackoff( () => fetch(`${apiUrl}/domains/check`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ domains }), }).then(r => r.json()), { maxRetries: 3 } ); } case 'whois_lookup': { // 자체 WHOIS API 서버 사용 (모든 TLD 지원) const domain = funcArgs.domain; try { const whoisRes = await retryWithBackoff( () => fetch(`${env.WHOIS_API_URL || 'https://whois-api-kappa-inoutercoms-projects.vercel.app'}/api/whois/${domain}`), { maxRetries: 3 } ); if (!whoisRes.ok) { return { error: `WHOIS 조회 실패: HTTP ${whoisRes.status}` }; } const whois = await whoisRes.json() as { error?: string; whois_supported?: boolean; ccSLD?: string; message_ko?: string; suggestion_ko?: string; domain?: string; available?: boolean; whois_server?: string; raw?: string; query_time_ms?: number; }; if (whois.error) { return { error: `WHOIS 조회 오류: ${whois.error}` }; } // ccSLD WHOIS 미지원 처리 if (whois.whois_supported === false) { return { domain: whois.domain, whois_supported: false, ccSLD: whois.ccSLD, message: whois.message_ko, suggestion: whois.suggestion_ko, }; } // raw WHOIS 응답을 그대로 반환 (AI가 파싱) return { domain: whois.domain, available: whois.available, whois_server: whois.whois_server, raw: whois.raw, query_time_ms: whois.query_time_ms, }; } catch (error) { logger.error('WHOIS 조회 오류', error as Error, { domain: funcArgs.domain }); if (error instanceof RetryError) { return { error: ERROR_MESSAGES.WHOIS_SERVICE_UNAVAILABLE }; } return { error: 'WHOIS 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' }; } } case 'register_domain': { if (!telegramUserId) { return { error: '도메인 등록에는 로그인이 필요합니다.' }; } const res = await fetch(`${apiUrl}/domains/register`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: funcArgs.domain, years: funcArgs.years || 1, telegram_id: telegramUserId, }), }); const result = await res.json() as { registered?: boolean; detail?: string; warning?: string }; if (!res.ok) { return { error: result.detail || '도메인 등록 실패' }; } // 등록 성공 시 user_domains 테이블에 추가 if (result.registered && db && userId) { try { await db.prepare( 'INSERT INTO user_domains (user_id, domain, verified, created_at) VALUES (?, ?, 1, datetime("now"))' ).bind(userId, funcArgs.domain).run(); logger.info('user_domains에 추가', { userId: maskUserId(userId), domain: funcArgs.domain }); } catch (dbError) { logger.error('user_domains 추가 실패', dbError as Error, { userId: maskUserId(userId), domain: funcArgs.domain }); result.warning = result.warning || ''; result.warning += ' (DB 기록 실패 - 수동 추가 필요)'; } } return result; } default: return { error: `Unknown function: ${funcName}` }; } } // 도메인 작업 직접 실행 (Agent 없이 코드로 처리) export async function executeDomainAction( action: string, args: { domain?: string; nameservers?: string[]; tld?: string }, allowedDomains: string[], env?: Env, telegramUserId?: string, db?: D1Database, userId?: number ): Promise { const { domain, nameservers, tld } = args; switch (action) { case 'list': { const result = await callNamecheapApi('list_domains', {}, allowedDomains, env, telegramUserId, db, userId); if (typeof result === 'object' && result !== null && 'error' in result) { return `🚫 ${(result as { error: string }).error}`; } const domains = result as NamecheapDomainListItem[]; if (!domains.length) return '📋 등록된 도메인이 없습니다.'; const list = domains.map((d: NamecheapDomainListItem) => `• ${d.name} (만료: ${d.expires})`).join('\n'); return `📋 내 도메인 목록 (${domains.length}개)\n\n${list}`; } case 'info': { if (!domain) return '🚫 도메인을 지정해주세요.'; // 내 도메인이 아니면 WHOIS 조회로 자동 전환 const lowerDomain = domain.toLowerCase(); const isMyDomain = allowedDomains.some(d => d.toLowerCase() === lowerDomain); if (!isMyDomain) { return executeDomainAction('whois', args, allowedDomains, env, telegramUserId, db, userId); } const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, env, telegramUserId, db, userId); if (isErrorResult(result)) { // 계정 내 도메인 정보 조회 실패 시 WHOIS로 폴백 return executeDomainAction('whois', args, allowedDomains, env, telegramUserId, db, userId); } // Type assertion after error check const domainInfo = result as { domain: string; created: string; expires: string; is_expired: boolean; auto_renew: boolean; is_locked: boolean; whois_guard: boolean; }; return `📋 ${domain} 정보\n\n• 생성일: ${domainInfo.created}\n• 만료일: ${domainInfo.expires}\n• 자동갱신: ${domainInfo.auto_renew ? '✅' : '❌'}\n• 잠금: ${domainInfo.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${domainInfo.whois_guard ? '✅' : '❌'}`; } case 'get_ns': { if (!domain) return '🚫 도메인을 지정해주세요.'; const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, env, telegramUserId, db, userId); if (isErrorResult(result)) return `🚫 ${result.error}`; const nsResult = result as { nameservers?: string[] } | string[]; const nameserverList = Array.isArray(nsResult) ? nsResult : (nsResult.nameservers || []); const nsList = nameserverList.map((ns: string) => `• ${ns}`).join('\n'); return `🌐 ${domain} 네임서버\n\n${nsList}`; } case 'set_ns': { if (!domain) return '🚫 도메인을 지정해주세요.'; if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.'; if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`; // 네임서버 형식 검증 const validationError = validateNameservers(nameservers); if (validationError) return validationError; const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId); if (isErrorResult(result)) return `🚫 ${result.error}`; return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`; } case 'check': { if (!domain) return '🚫 도메인을 지정해주세요.'; const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId); if (isErrorResult(result)) return `🚫 ${result.error}`; const checkResult = result as NamecheapCheckResult; const available = checkResult[domain]; if (available) { // 가격도 함께 조회 const domainTld = domain.split('.').pop() || ''; let price: number | undefined; // 캐시 확인 if (env?.RATE_LIMIT_KV) { const cached = await getCachedTLDPrice(env.RATE_LIMIT_KV, domainTld); if (cached) { price = cached.krw; } } // 캐시 미스 시 API 호출 if (!price) { const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId); if (isNamecheapPriceResponse(priceResult)) { price = priceResult.krw || priceResult.register_krw; // 캐시 저장 if (env?.RATE_LIMIT_KV) { await setCachedTLDPrice(env.RATE_LIMIT_KV, domainTld, priceResult); } } } const priceStr = price ? `${price.toLocaleString()}원/년` : '가격 조회 중'; return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${priceStr}\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`; } return `❌ ${domain}은 이미 등록된 도메인입니다.`; } case 'whois': { if (!domain) return '🚫 도메인을 지정해주세요.'; const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, env, telegramUserId, db, userId); if (isErrorResult(result)) return `🚫 ${result.error}`; const whoisResult = result as { whois_supported?: boolean; message?: string; suggestion?: string; raw?: string; available?: boolean; }; // ccSLD WHOIS 미지원 if (whoisResult.whois_supported === false) { return `🔍 ${domain} WHOIS\n\n⚠️ ${whoisResult.message}\n💡 ${whoisResult.suggestion}`; } // raw WHOIS 데이터에서 주요 정보 추출 const raw = whoisResult.raw || ''; const extractField = (patterns: RegExp[]): string => { for (const pattern of patterns) { const match = raw.match(pattern); if (match) return match[1].trim(); } return '-'; }; const created = extractField([ /Creation Date:\s*(.+)/i, /Created Date:\s*(.+)/i, /Registration Date:\s*(.+)/i, /created:\s*(.+)/i, ]); const expires = extractField([ /Registry Expiry Date:\s*(.+)/i, /Expiration Date:\s*(.+)/i, /Expiry Date:\s*(.+)/i, /expires:\s*(.+)/i, ]); const updated = extractField([ /Updated Date:\s*(.+)/i, /Last Updated:\s*(.+)/i, /modified:\s*(.+)/i, ]); const registrar = extractField([ /Registrar:\s*(.+)/i, /Sponsoring Registrar:\s*(.+)/i, ]); const registrarUrl = extractField([ /Registrar URL:\s*(.+)/i, ]); const registrant = extractField([ /Registrant Organization:\s*(.+)/i, /Registrant Name:\s*(.+)/i, /org:\s*(.+)/i, ]); const registrantCountry = extractField([ /Registrant Country:\s*(.+)/i, /Registrant State\/Province:\s*(.+)/i, ]); const statusMatch = raw.match(/Domain Status:\s*(.+)/gi); const statuses = statusMatch ? statusMatch.map((s: string) => s.replace(/Domain Status:\s*/i, '').split(' ')[0].trim()).slice(0, 3) : []; const dnssec = extractField([ /DNSSEC:\s*(.+)/i, ]); const nsMatch = raw.match(/Name Server:\s*(.+)/gi); const nameservers = nsMatch ? nsMatch.map((ns: string) => ns.replace(/Name Server:\s*/i, '').trim()).slice(0, 4) : []; let response = `🔍 ${domain} WHOIS 정보\n\n`; response += `📅 날짜\n`; response += `• 등록일: ${created}\n`; response += `• 만료일: ${expires}\n`; if (updated !== '-') response += `• 수정일: ${updated}\n`; response += `\n🏢 등록 정보\n`; response += `• 등록기관: ${registrar}\n`; if (registrarUrl !== '-') response += `• URL: ${registrarUrl}\n`; if (registrant !== '-') response += `• 등록자: ${registrant}\n`; if (registrantCountry !== '-') response += `• 국가: ${registrantCountry}\n`; response += `\n🌐 기술 정보\n`; response += `• 네임서버: ${nameservers.length ? nameservers.join(', ') : '-'}\n`; if (statuses.length) response += `• 상태: ${statuses.join(', ')}\n`; if (dnssec !== '-') response += `• DNSSEC: ${dnssec}`; // Type guard to check if result has 'available' property const typedResult = result as { available?: boolean }; if (typedResult.available === true) { response += `\n\n✅ 이 도메인은 등록 가능합니다!`; } return response.trim(); } case 'price': { // tld, domain, 또는 ".com" 형식 모두 지원 let targetTld = tld || domain?.replace(/^\./, '').split('.').pop(); if (!targetTld) return '🚫 TLD를 지정해주세요. (예: com, io, net)'; // 캐시 확인 if (env?.RATE_LIMIT_KV) { const cached = await getCachedTLDPrice(env.RATE_LIMIT_KV, targetTld); if (cached) { return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${cached.krw.toLocaleString()}원/년\n\n📌 캐시된 정보 (${new Date(cached.cached_at).toLocaleString('ko-KR')})`; } } // API 호출 const result = await callNamecheapApi('get_price', { tld: targetTld }, allowedDomains, env, telegramUserId, db, userId); if (isErrorResult(result)) return `🚫 ${result.error}`; // Type assertion after error check const priceResult = result as NamecheapPriceResponse; // 캐시 저장 if (env?.RATE_LIMIT_KV) { await setCachedTLDPrice(env.RATE_LIMIT_KV, targetTld, priceResult); } // API 응답: { tld, usd, krw } const price = priceResult.krw ?? priceResult.register_krw ?? 0; return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${price.toLocaleString()}원/년`; } case 'cheapest': { // 캐시 확인 if (env?.RATE_LIMIT_KV) { const cached = await getCachedAllPrices(env.RATE_LIMIT_KV); if (cached) { const sorted = cached .filter((p: NamecheapPriceResponse) => p.krw > 0) .sort((a: NamecheapPriceResponse, b: NamecheapPriceResponse) => a.krw - b.krw) .slice(0, 15); const list = sorted .map((p: NamecheapPriceResponse, idx: number) => `${idx + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`) .join('\n'); return `💰 가장 저렴한 TLD TOP 15\n\n${list}\n\n📌 캐시된 정보\n💡 특정 TLD 가격은 ".com 가격" 형식으로 조회`; } } // API 호출 const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, env, telegramUserId, db, userId); if (typeof result === 'object' && result !== null && 'error' in result) { return `🚫 ${(result as { error: string }).error}`; } // 캐시 저장 if (env?.RATE_LIMIT_KV && Array.isArray(result)) { await setCachedAllPrices(env.RATE_LIMIT_KV, result as NamecheapPriceResponse[]); } // 가격 > 0인 TLD만 필터링, krw 기준 정렬 const sorted = (result as NamecheapPriceResponse[]) .filter((p: NamecheapPriceResponse) => p.krw > 0) .sort((a: NamecheapPriceResponse, b: NamecheapPriceResponse) => a.krw - b.krw) .slice(0, 15); if (sorted.length === 0) { return '🚫 TLD 가격 정보를 가져올 수 없습니다.'; } const list = sorted.map((p: NamecheapPriceResponse, 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 '🚫 도메인 등록에는 로그인이 필요합니다.'; const domainTld = domain.split('.').pop() || ''; // 병렬 실행: 가용성 확인, 가격 조회, 잔액 조회 const [checkResult, priceResult, balanceRow] = await Promise.all([ callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId), callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId), db && userId ? db.prepare('SELECT balance FROM user_deposits WHERE user_id = ?').bind(userId).first<{ balance: number }>() : Promise.resolve(null) ]); // 1. 가용성 확인 결과 처리 if (isErrorResult(checkResult)) return `🚫 ${checkResult.error}`; const availability = checkResult as NamecheapCheckResult; if (!availability[domain]) return `❌ ${domain}은 이미 등록된 도메인입니다.`; // 2. 가격 조회 결과 처리 if (isErrorResult(priceResult)) return `🚫 가격 조회 실패: ${priceResult.error}`; const priceData = priceResult as NamecheapPriceResponse; const price = priceData.krw ?? priceData.register_krw ?? 0; // 3. 잔액 조회 결과 처리 const balance = balanceRow?.balance || 0; // 4. 확인 페이지 생성 (인라인 버튼 포함) if (balance >= price) { // 버튼 데이터를 특수 마커로 포함 const keyboardData = JSON.stringify({ type: 'domain_register', domain: domain, price: price }); return `${MESSAGE_MARKERS.KEYBOARD}${keyboardData}${MESSAGE_MARKERS.KEYBOARD_END} 📋 도메인 등록 확인 • 도메인: ${domain} • 가격: ${price.toLocaleString()}원 (예치금에서 차감) • 현재 잔액: ${balance.toLocaleString()}원 ✅ • 등록 기간: 1년 📌 등록자 정보 서비스 기본 정보로 등록됩니다. (WHOIS Guard가 적용되어 개인정보는 비공개) ⚠️ 주의사항 도메인 등록 후에는 취소 및 환불이 불가능합니다.`; } else { const shortage = price - balance; return `📋 도메인 등록 확인 • 도메인: ${domain} • 가격: ${price.toLocaleString()}원 • 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족 • 부족 금액: ${shortage.toLocaleString()}원 💳 입금 계좌 하나은행 427-910018-27104 (주식회사 아이언클래드) 입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`; } } default: return `🚫 알 수 없는 작업: ${action}`; } } export async function executeManageDomain( args: { action: string; domain?: string; nameservers?: string[]; tld?: string }, env?: Env, telegramUserId?: string, db?: D1Database ): Promise { logger.info('manage_domain 시작 (에이전트 위임)', { action: args.action, userId: maskUserId(telegramUserId) }); if (!db || !telegramUserId || !env) { return '❌ 도메인 관리 기능을 사용할 수 없습니다.'; } // Import processDomainConsultation dynamically to avoid circular deps const { processDomainConsultation } = await import('../agents/domain-agent'); // Convert the action + args to a natural language message for the agent const userMessage = buildDomainMessage(args); logger.info('도메인 메시지 변환', { message: userMessage }); // Delegate to domain agent const response = await processDomainConsultation(db, telegramUserId, userMessage, env); // If passthrough, the message wasn't domain-related if (response === '__PASSTHROUGH__') { return '도메인 관련 요청을 이해하지 못했습니다. 다시 말씀해주세요.'; } return response; } // Helper to convert action args to natural message function buildDomainMessage(args: { action: string; domain?: string; nameservers?: string[]; tld?: string }): string { switch (args.action) { case 'check': return `${args.domain} 도메인 가용성 확인해줘`; case 'whois': return `${args.domain} WHOIS 조회해줘`; case 'price': return `${args.tld || args.domain || 'com'} 가격 알려줘`; case 'cheapest': return '가장 저렴한 TLD 목록 보여줘'; case 'list': return '내 도메인 목록 보여줘'; case 'info': return `${args.domain} 정보 보여줘`; case 'get_ns': return `${args.domain} 네임서버 조회해줘`; case 'set_ns': return `${args.domain} 네임서버를 ${args.nameservers?.join(', ')}로 변경해줘`; case 'register': return `${args.domain} 등록해줘`; default: return `도메인 ${args.action} 작업`; } } export async function executeSuggestDomains(args: { keywords: string }, env?: Env): Promise { const { keywords } = args; logger.info('시작', { keywords }); if (!env?.OPENAI_API_KEY) { return '🚫 도메인 추천 기능이 설정되지 않았습니다. (OPENAI_API_KEY 미설정)'; } if (!env?.NAMECHEAP_API_KEY) { return '🚫 도메인 추천 기능이 설정되지 않았습니다. (NAMECHEAP_API_KEY 미설정)'; } // Store API key after null check const namecheapApiKey = env.NAMECHEAP_API_KEY; try { const namecheapApiUrl = env.NAMECHEAP_API_URL || 'https://namecheap.api.inouter.com'; const TARGET_COUNT = 10; const MAX_RETRIES = 3; const availableDomains: { domain: string; price?: number }[] = []; const checkedDomains = new Set(); let retryCount = 0; // 10개 이상 등록 가능 도메인을 찾을 때까지 반복 while (availableDomains.length < TARGET_COUNT && retryCount < MAX_RETRIES) { retryCount++; const excludeList = [...checkedDomains].slice(-30).join(', '); // Step 1: GPT에게 도메인 아이디어 생성 요청 const ideaResponse = await retryWithBackoff( () => fetch(getOpenAIUrl(env), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: `당신은 도메인 이름 전문가입니다. 주어진 키워드/비즈니스 설명을 바탕으로 창의적이고 기억하기 쉬운 도메인 이름을 제안합니다. 규칙: - 정확히 15개의 도메인 이름을 제안하세요 - 다양한 TLD 사용: .com, .io, .net, .co, .app, .dev, .site, .xyz, .me - 짧고 기억하기 쉬운 이름 (2-3 단어 조합) - 트렌디한 접미사 활용: hub, lab, spot, nest, base, cloud, stack, flow, zone, pro - JSON 배열로만 응답하세요. 설명 없이 도메인 목록만. ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''} 예시 응답: ["coffeenest.com", "brewlab.io", "beanspot.co"]` }, { role: 'user', content: `키워드: ${keywords}` } ], max_tokens: 500, temperature: 0.9, }), }), { maxRetries: 2 } // 도메인 추천은 중요도가 낮으므로 재시도 2회 ); if (!ideaResponse.ok) { if (availableDomains.length > 0) break; // 이미 찾은 게 있으면 그것으로 진행 return '🚫 도메인 아이디어 생성 중 오류가 발생했습니다.'; } const ideaData = await ideaResponse.json() as OpenAIResponse; const ideaContent = ideaData.choices?.[0]?.message?.content || '[]'; let domains: string[]; try { domains = JSON.parse(ideaContent); if (!Array.isArray(domains)) domains = []; } catch { const domainRegex = /[\w-]+\.(com|io|net|co|app|dev|site|org|xyz|me)/gi; domains = ideaContent.match(domainRegex) || []; } // 이미 체크한 도메인 제외 const newDomains = domains.filter(d => !checkedDomains.has(d.toLowerCase())); if (newDomains.length === 0) continue; newDomains.forEach(d => checkedDomains.add(d.toLowerCase())); // Step 2: 가용성 확인 const checkResponse = await retryWithBackoff( () => fetch(`${namecheapApiUrl}/domains/check`, { method: 'POST', headers: { 'X-API-Key': namecheapApiKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ domains: newDomains }), }), { maxRetries: 3 } ); if (!checkResponse.ok) continue; const checkRaw = await checkResponse.json() as NamecheapCheckResult; // 등록 가능한 도메인만 추가 for (const [domain, isAvailable] of Object.entries(checkRaw)) { if (isAvailable && availableDomains.length < TARGET_COUNT) { availableDomains.push({ domain }); } } } if (availableDomains.length === 0) { return `🎯 **${keywords}** 관련 도메인:\n\n❌ 등록 가능한 도메인을 찾지 못했습니다.\n다른 키워드로 다시 시도해주세요.`; } // Step 3: 가격 조회 (병렬 처리 + 캐시 활용) const tldPrices: Record = {}; const uniqueTlds = [...new Set(availableDomains.map(d => d.domain.split('.').pop() || ''))]; logger.info('가격 조회 시작', { tldCount: uniqueTlds.length, tlds: uniqueTlds }); // 병렬 처리로 가격 조회 const pricePromises = uniqueTlds.map(async (tld) => { try { // 캐시 확인 if (env?.RATE_LIMIT_KV) { const cached = await getCachedTLDPrice(env.RATE_LIMIT_KV, tld); if (cached) { logger.info('캐시 히트', { tld, price: cached.krw }); return { tld, price: cached.krw, cached: true }; } } // 캐시 미스 시 API 호출 const priceRes = await retryWithBackoff( () => fetch(`${namecheapApiUrl}/prices/${tld}`, { headers: { 'X-API-Key': namecheapApiKey }, }), { maxRetries: 3 } ); if (!priceRes.ok) { logger.warn('가격 조회 실패', { tld, status: priceRes.status }); return { tld, price: null, error: `HTTP ${priceRes.status}` }; } const priceData = await priceRes.json() as NamecheapPriceResponse; const price = priceData.krw || 0; // 캐시 저장 if (env?.RATE_LIMIT_KV && price > 0) { await setCachedTLDPrice(env.RATE_LIMIT_KV, tld, priceData); } logger.info('API 가격 조회 완료', { tld, price }); return { tld, price, cached: false }; } catch (error) { logger.error('가격 조회 에러', error as Error, { tld }); return { tld, price: null, error: '가격 조회 실패' }; } }); const priceResults = await Promise.all(pricePromises); // 결과 집계 let cacheHits = 0; let apiFetches = 0; let errors = 0; priceResults.forEach(({ tld, price, cached }) => { if (price !== null) { tldPrices[tld] = price; if (cached) cacheHits++; else apiFetches++; } else { errors++; } }); logger.info('가격 조회 완료', { total: uniqueTlds.length, cacheHits, apiFetches, errors, }); // Step 4: 결과 포맷팅 (등록 가능한 것만) let response = `🎯 **${keywords}** 관련 도메인:\n\n`; availableDomains.forEach((d, i) => { const tld = d.domain.split('.').pop() || ''; const price = tldPrices[tld]; const priceStr = price ? `${price.toLocaleString()}원/년` : '가격 조회 중'; response += `${i + 1}. ${d.domain} - ${priceStr}\n`; }); response += `\n등록하시려면 번호나 도메인명을 말씀해주세요.`; return response; } catch (error) { logger.error('도메인 추천 중 오류', error as Error, { keywords }); if (error instanceof RetryError) { return ERROR_MESSAGES.DOMAIN_SERVICE_UNAVAILABLE; } return '🚫 도메인 추천 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; } }