Files
telegram-bot-workers/src/tools/domain-tool.ts
kappa 45e0677ab0 refactor: code quality improvements (P3)
## Type Safety
- Add zod runtime validation for external API responses
  * Namecheap API responses (domain-register.ts)
  * n8n webhook responses (n8n-service.ts)
  * User request bodies (routes/api.ts)
  * Replaced unsafe type assertions with safeParse()
  * Proper error handling and logging

## Dead Code Removal
- Remove unused callDepositAgent function (127 lines)
  * Legacy Assistants API code no longer needed
  * Now using direct code execution
  * File reduced from 469 → 345 lines (26.4% reduction)

## Configuration Management
- Extract hardcoded URLs to environment variables
  * Added 7 new vars in wrangler.toml:
    OPENAI_API_BASE, NAMECHEAP_API_URL, WHOIS_API_URL,
    CONTEXT7_API_BASE, BRAVE_API_BASE, WTTR_IN_URL, HOSTING_SITE_URL
  * Updated Env interface in types.ts
  * All URLs have fallback to current production values
  * Enables environment-specific configuration (dev/staging/prod)

## Dependencies
- Add zod 4.3.5 for runtime type validation

## Files Modified
- Configuration: wrangler.toml, types.ts, package.json
- Services: 11 TypeScript files with URL/validation updates
- Total: 15 files, +196/-189 lines

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 22:06:01 +09:00

979 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Env } from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger, maskUserId } from '../utils/logger';
const logger = createLogger('domain-tool');
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
function getOpenAIUrl(env: Env): string {
const base = env.OPENAI_API_BASE || 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai';
return `${base}/chat/completions`;
}
// KV 캐싱 인터페이스
interface CachedTLDPrice {
tld: string;
krw: number;
usd?: number;
cached_at: string;
}
// 단일 TLD 가격 캐시 조회
async function getCachedTLDPrice(
kv: KVNamespace,
tld: string
): Promise<CachedTLDPrice | null> {
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: any
): Promise<void> {
try {
const key = `tld_price:${tld}`;
const data: CachedTLDPrice = {
tld,
krw: price.krw || price.register_krw,
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<any[] | null> {
try {
const key = 'tld_price:all';
const cached = await kv.get(key, 'json');
if (cached) {
logger.info('TLDCache HIT: all prices');
return cached as any[];
}
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: any[]
): Promise<void> {
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<string, any>,
allowedDomains: string[],
env?: Env,
telegramUserId?: string,
db?: D1Database,
userId?: number
): Promise<any> {
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.anvil.it.com';
// 도메인 권한 체크 (쓰기 작업만)
// 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능
if (['set_nameservers', 'create_child_ns', 'delete_child_ns'].includes(funcName)) {
if (!allowedDomains.includes(funcArgs.domain)) {
return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` };
}
}
switch (funcName) {
case 'list_domains': {
const result = await retryWithBackoff(
() => fetch(`${apiUrl}/domains?page=${funcArgs.page || 1}&page_size=${funcArgs.page_size || 100}`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
) as any[];
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
const convertDate = (date: string) => {
const [month, day, year] = date.split('/');
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
};
// 허용된 도메인만 필터링, 날짜는 ISO 형식으로 변환
return result
.filter((d: any) => allowedDomains.includes(d.name))
.map((d: any) => ({
...d,
created: convertDate(d.created),
expires: convertDate(d.expires),
user: undefined, // 민감 정보 제거
}));
}
case 'get_domain_info': {
// 목록 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 any[];
const domainInfo = domains.find((d: any) => d.name === funcArgs.domain);
if (!domainInfo) {
return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` };
}
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
const convertDate = (date: string) => {
const [month, day, year] = date.split('/');
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
};
// 민감 정보 필터링 (user/owner 제거), 날짜는 ISO 형식으로 변환
return {
domain: domainInfo.name,
created: convertDate(domainInfo.created),
expires: convertDate(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':
return retryWithBackoff(
() => fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
);
case 'set_nameservers': {
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
method: 'PUT',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: funcArgs.domain, nameservers: funcArgs.nameservers }),
});
const text = await res.text();
if (!res.ok) {
// Namecheap 에러 메시지 파싱
if (text.includes('subordinate hosts') || text.includes('Non existen')) {
return {
error: `네임서버 변경 실패: ${funcArgs.nameservers.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 any;
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 any;
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 any;
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 tld = funcArgs.tld?.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': {
// 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: funcArgs.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 any;
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('오류', error as Error, { domain: funcArgs.domain });
if (error instanceof RetryError) {
return { error: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.' };
}
return { error: `WHOIS 조회 오류: ${String(error)}` };
}
}
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 any;
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 없이 코드로 처리)
async function executeDomainAction(
action: string,
args: { domain?: string; nameservers?: string[]; tld?: string },
allowedDomains: string[],
env?: Env,
telegramUserId?: string,
db?: D1Database,
userId?: number
): Promise<string> {
const { domain, nameservers, tld } = args;
switch (action) {
case 'list': {
const result = await callNamecheapApi('list_domains', {}, allowedDomains, env, telegramUserId, db, userId);
if (result.error) return `🚫 ${result.error}`;
if (!result.length) return '📋 등록된 도메인이 없습니다.';
const list = result.map((d: any) => `${d.name} (만료: ${d.expires})`).join('\n');
return `📋 내 도메인 목록 (${result.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 (result.error) {
// 계정 내 도메인 정보 조회 실패 시 WHOIS로 폴백
return executeDomainAction('whois', args, allowedDomains, env, telegramUserId, db, userId);
}
return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`;
}
case 'get_ns': {
if (!domain) return '🚫 도메인을 지정해주세요.';
const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, env, telegramUserId, db, userId);
if (result.error) return `🚫 ${result.error}`;
const nsList = (result.nameservers || result).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 result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId);
if (result.error) 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 (result.error) return `🚫 ${result.error}`;
const available = result[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);
price = priceResult.krw || priceResult.register_krw;
// 캐시 저장
if (env?.RATE_LIMIT_KV) {
await setCachedTLDPrice(env.RATE_LIMIT_KV, domainTld, priceResult);
}
}
return `${domain}은 등록 가능합니다.\n\n💰 가격: ${price?.toLocaleString()}원/년\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`;
}
return `${domain}은 이미 등록된 도메인입니다.`;
}
case 'whois': {
if (!domain) return '🚫 도메인을 지정해주세요.';
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, env, telegramUserId, db, userId);
if (result.error) return `🚫 ${result.error}`;
// ccSLD WHOIS 미지원
if (result.whois_supported === false) {
return `🔍 ${domain} WHOIS\n\n⚠ ${result.message}\n💡 ${result.suggestion}`;
}
// raw WHOIS 데이터에서 주요 정보 추출
const raw = result.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}`;
if (result.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 (result.error) return `🚫 ${result.error}`;
// 캐시 저장
if (env?.RATE_LIMIT_KV) {
await setCachedTLDPrice(env.RATE_LIMIT_KV, targetTld, result);
}
// API 응답: { tld, usd, krw }
const price = result.krw || result.register_krw;
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: any) => p.krw > 0)
.sort((a: any, b: any) => a.krw - b.krw)
.slice(0, 15);
const list = sorted
.map((p: any, 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 (result.error) return `🚫 ${result.error}`;
// 캐시 저장
if (env?.RATE_LIMIT_KV && Array.isArray(result)) {
await setCachedAllPrices(env.RATE_LIMIT_KV, result);
}
// 가격 > 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 '🚫 도메인 등록에는 로그인이 필요합니다.';
// 1. 가용성 확인
const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
if (checkResult.error) return `🚫 ${checkResult.error}`;
if (!checkResult[domain]) return `${domain}은 이미 등록된 도메인입니다.`;
// 2. 가격 조회
const domainTld = domain.split('.').pop() || '';
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
if (priceResult.error) return `🚫 가격 조회 실패: ${priceResult.error}`;
const price = priceResult.krw || priceResult.register_krw;
// 3. 잔액 조회
let balance = 0;
if (db && userId) {
const balanceRow = await db.prepare('SELECT balance FROM user_deposits WHERE user_id = ?').bind(userId).first<{ balance: number }>();
balance = balanceRow?.balance || 0;
}
// 4. 확인 페이지 생성 (인라인 버튼 포함)
if (balance >= price) {
// 버튼 데이터를 특수 마커로 포함
const keyboardData = JSON.stringify({
type: 'domain_register',
domain: domain,
price: price
});
return `__KEYBOARD__${keyboardData}__END__
📋 <b>도메인 등록 확인</b>
• 도메인: <code>${domain}</code>
• 가격: ${price.toLocaleString()}원 (예치금에서 차감)
• 현재 잔액: ${balance.toLocaleString()}원 ✅
• 등록 기간: 1년
📌 <b>등록자 정보</b>
서비스 기본 정보로 등록됩니다.
(WHOIS Guard가 적용되어 개인정보는 비공개)
⚠️ <b>주의사항</b>
도메인 등록 후에는 취소 및 환불이 불가능합니다.`;
} else {
const shortage = price - balance;
return `📋 <b>도메인 등록 확인</b>
• 도메인: <code>${domain}</code>
• 가격: ${price.toLocaleString()}
• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족
• 부족 금액: ${shortage.toLocaleString()}
💳 <b>입금 계좌</b>
하나은행 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<string> {
const { action, domain, nameservers, tld } = args;
logger.info('시작', { action, domain, userId: maskUserId(telegramUserId), hasDb: !!db });
// 소유권 검증 (DB 조회)
if (!telegramUserId || !db) {
logger.info('실패: telegramUserId 또는 db 없음');
return '🚫 도메인 관리 권한이 없습니다.';
}
let userDomains: string[] = [];
let userId: number | undefined;
try {
const user = await db.prepare(
'SELECT id FROM users WHERE telegram_id = ?'
).bind(telegramUserId).first<{ id: number }>();
if (!user) {
return '🚫 도메인 관리 권한이 없습니다.';
}
userId = user.id;
// 사용자 소유 도메인 전체 목록 조회
const domains = await db.prepare(
'SELECT domain FROM user_domains WHERE user_id = ? AND verified = 1'
).bind(user.id).all<{ domain: string }>();
userDomains = domains.results?.map(d => d.domain) || [];
logger.info('소유 도메인', { userDomains });
} catch (error) {
logger.error('DB 오류', error as Error);
return '🚫 권한 확인 중 오류가 발생했습니다.';
}
// 코드로 직접 처리 (Agent 없이)
try {
const result = await executeDomainAction(
action,
{ domain, nameservers, tld },
userDomains,
env,
telegramUserId,
db,
userId
);
logger.info('완료', { result: result?.slice(0, 100) });
return result;
} catch (error) {
logger.error('오류', error as Error);
return `🚫 도메인 관리 오류: ${String(error)}`;
}
}
export async function executeSuggestDomains(args: { keywords: string }, env?: Env): Promise<string> {
const { keywords } = args;
logger.info('시작', { keywords });
if (!env?.OPENAI_API_KEY) {
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (OPENAI_API_KEY 미설정)';
}
if (!env?.NAMECHEAP_API_KEY) {
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (NAMECHEAP_API_KEY 미설정)';
}
try {
const namecheapApiUrl = env.NAMECHEAP_API_URL || 'https://namecheap-api.anvil.it.com';
const TARGET_COUNT = 10;
const MAX_RETRIES = 3;
const availableDomains: { domain: string; price?: number }[] = [];
const checkedDomains = new Set<string>();
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 any;
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': env.NAMECHEAP_API_KEY!, // Already checked above
'Content-Type': 'application/json',
},
body: JSON.stringify({ domains: newDomains }),
}),
{ maxRetries: 3 }
);
if (!checkResponse.ok) continue;
const checkRaw = await checkResponse.json() as Record<string, boolean>;
// 등록 가능한 도메인만 추가
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<string, number> = {};
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': env.NAMECHEAP_API_KEY! }, // Already checked above
}),
{ 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 { krw?: number };
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: String(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 `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
}
return `🚫 도메인 추천 중 오류가 발생했습니다: ${String(error)}`;
}
}