1101 lines
41 KiB
TypeScript
1101 lines
41 KiB
TypeScript
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 '🚫 도메인 추천 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||
}
|
||
}
|