Files
telegram-bot-workers/src/tools/domain-tool.ts
kaffa 90d2f96ae9
Some checks failed
TypeScript CI / build (push) Has been cancelled
chore: anvil.it.com → inouter.com
2026-03-27 16:16:38 +00:00

1101 lines
41 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,
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<string, unknown>
function getStringValue(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key];
return typeof value === 'string' ? value : undefined;
}
// Helper to safely get number value from Record<string, unknown>
function getNumberValue(obj: Record<string, unknown>, key: string): number | undefined {
const value = obj[key];
return typeof value === 'number' ? value : undefined;
}
// Helper to safely get array value from Record<string, unknown>
function getArrayValue<T>(obj: Record<string, unknown>, 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<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: NamecheapPriceResponse
): Promise<void> {
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<NamecheapPriceResponse[] | 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 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<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, unknown>,
allowedDomains: string[],
env?: Env,
telegramUserId?: string,
db?: D1Database,
userId?: number
): Promise<unknown> {
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<string>(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<string>(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<string> {
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}
📋 <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> {
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<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 미설정)';
}
// 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<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 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<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': 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 '🚫 도메인 추천 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}