feat: 도메인 시스템 개선 + 검색 한글→영문 번역
주요 변경: - Domain Agent 제거, 코드 직접 처리로 전환 - suggest_domains: 등록 가능 도메인만 표시, 10개 미만 시 재시도 - search_web: 한글 검색어 자동 영문 번역 (GPT-4o-mini) - WHOIS: raw 데이터 파싱으로 상세 정보 추출 - 가격 조회: API 필드명 수정 (register_krw → krw) - 동적 도구 로딩 시스템 추가 - 문서 정리 및 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,16 +119,30 @@ const tools = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'manage_domain',
|
||||
description: '도메인을 관리합니다. 도메인 목록 조회, 도메인 정보 확인, 네임서버 조회/변경, 도메인 가용성 확인, 계정 잔액 조회, TLD 가격 조회 등을 수행할 수 있습니다.',
|
||||
description: '도메인 관리 및 정보 조회. ".com 가격", ".io 가격" 같은 TLD 가격 조회, 도메인 등록, WHOIS 조회, 네임서버 관리 등을 처리합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: '도메인 관리 요청 (예: 도메인 목록 보여줘, anvil.it.com 네임서버 확인, example.com 등록 가능한지 확인)',
|
||||
enum: ['register', 'check', 'whois', 'list', 'info', 'get_ns', 'set_ns', 'price'],
|
||||
description: 'price: TLD 가격 조회 (.com 가격, .io 가격), register: 도메인 등록, check: 가용성 확인, whois: WHOIS 조회, list: 내 도메인 목록, info: 도메인 상세정보, get_ns/set_ns: 네임서버 조회/변경',
|
||||
},
|
||||
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: ['query'],
|
||||
required: ['action'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -149,10 +163,222 @@ const tools = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'suggest_domains',
|
||||
description: '키워드나 비즈니스 설명을 기반으로 도메인 이름을 추천합니다. 창의적인 도메인 아이디어를 생성하고 가용성을 확인하여 등록 가능한 도메인만 가격과 함께 제안합니다. "도메인 추천", "도메인 제안", "도메인 아이디어" 등의 요청에 사용하세요.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keywords: {
|
||||
type: 'string',
|
||||
description: '도메인 추천을 위한 키워드나 비즈니스 설명 (예: 커피숍, IT 스타트업, 서버 호스팅)',
|
||||
},
|
||||
},
|
||||
required: ['keywords'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 동적 도구 로딩 시스템
|
||||
// ============================================
|
||||
|
||||
// 도구 카테고리 정의
|
||||
const TOOL_CATEGORIES: Record<string, string[]> = {
|
||||
domain: ['manage_domain', 'suggest_domains'],
|
||||
deposit: ['manage_deposit'],
|
||||
weather: ['get_weather'],
|
||||
search: ['search_web', 'lookup_docs'],
|
||||
utility: ['get_current_time', 'calculate'],
|
||||
};
|
||||
|
||||
// 카테고리 감지 패턴 (느슨하게)
|
||||
const CATEGORY_PATTERNS: Record<string, RegExp> = {
|
||||
domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i,
|
||||
deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i,
|
||||
weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i,
|
||||
search: /검색|찾아|뭐야|뭔가요|뉴스|최신/i,
|
||||
};
|
||||
|
||||
// 메시지 기반 도구 선택
|
||||
function selectToolsForMessage(message: string): typeof tools {
|
||||
const selectedCategories = new Set<string>(['utility']); // 항상 포함
|
||||
|
||||
for (const [category, pattern] of Object.entries(CATEGORY_PATTERNS)) {
|
||||
if (pattern.test(message)) {
|
||||
selectedCategories.add(category);
|
||||
}
|
||||
}
|
||||
|
||||
// 패턴 매칭 없으면 전체 도구 사용 (폴백)
|
||||
if (selectedCategories.size === 1) {
|
||||
console.log('[ToolSelector] 패턴 매칭 없음 → 전체 도구 사용');
|
||||
return tools;
|
||||
}
|
||||
|
||||
const selectedNames = new Set(
|
||||
[...selectedCategories].flatMap(cat => TOOL_CATEGORIES[cat] || [])
|
||||
);
|
||||
|
||||
const selectedTools = tools.filter(t => selectedNames.has(t.function.name));
|
||||
|
||||
console.log('[ToolSelector] 메시지:', message);
|
||||
console.log('[ToolSelector] 카테고리:', [...selectedCategories].join(', '));
|
||||
console.log('[ToolSelector] 선택된 도구:', selectedTools.map(t => t.function.name).join(', '));
|
||||
|
||||
return selectedTools;
|
||||
}
|
||||
|
||||
// 도메인 추천 함수
|
||||
async function suggestDomains(keywords: string, apiKey: string): Promise<string> {
|
||||
const namecheapApiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
||||
const namecheapApiUrl = 'https://namecheap-api.anvil.it.com';
|
||||
const TARGET_COUNT = 10;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
try {
|
||||
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 fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
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 fetch(`${namecheapApiUrl}/domains/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': namecheapApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ domains: newDomains }),
|
||||
});
|
||||
|
||||
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() || ''))];
|
||||
|
||||
for (const tld of uniqueTlds) {
|
||||
try {
|
||||
const priceRes = await fetch(`${namecheapApiUrl}/prices/${tld}`, {
|
||||
headers: { 'X-API-Key': namecheapApiKey },
|
||||
});
|
||||
if (priceRes.ok) {
|
||||
const priceData = await priceRes.json() as { krw?: number };
|
||||
tldPrices[tld] = priceData.krw || 0;
|
||||
}
|
||||
} catch {
|
||||
// 가격 조회 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.error('[suggestDomains] 오류:', error);
|
||||
return `🚫 도메인 추천 중 오류가 발생했습니다: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Namecheap API 호출 (allowedDomains로 필터링)
|
||||
async function callNamecheapApi(funcName: string, funcArgs: Record<string, any>, allowedDomains: string[]): Promise<any> {
|
||||
async function callNamecheapApi(
|
||||
funcName: string,
|
||||
funcArgs: Record<string, any>,
|
||||
allowedDomains: string[],
|
||||
telegramUserId?: string,
|
||||
db?: D1Database,
|
||||
userId?: number
|
||||
): Promise<any> {
|
||||
const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
||||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
||||
|
||||
@@ -322,130 +548,256 @@ async function callNamecheapApi(funcName: string, funcArgs: Record<string, any>,
|
||||
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();
|
||||
console.log(`[register_domain] user_domains에 추가: user_id=${userId}, domain=${funcArgs.domain}`);
|
||||
} catch (dbError) {
|
||||
console.error('[register_domain] user_domains 추가 실패:', dbError);
|
||||
result.warning = result.warning || '';
|
||||
result.warning += ' (DB 기록 실패 - 수동 추가 필요)';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
return { error: `Unknown function: ${funcName}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 도메인 에이전트 (Assistants API - 기존 Agent 활용)
|
||||
async function callDomainAgent(
|
||||
apiKey: string,
|
||||
assistantId: string,
|
||||
query: string,
|
||||
allowedDomains: string[] = []
|
||||
// 도메인 작업 직접 실행 (Agent 없이 코드로 처리)
|
||||
async function executeDomainAction(
|
||||
action: string,
|
||||
args: { domain?: string; nameservers?: string[]; tld?: string },
|
||||
allowedDomains: string[],
|
||||
telegramUserId?: string,
|
||||
db?: D1Database,
|
||||
userId?: number
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 1. Thread 생성
|
||||
const threadRes = await fetch('https://api.openai.com/v1/threads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`;
|
||||
const thread = await threadRes.json() as { id: string };
|
||||
const { domain, nameservers, tld } = args;
|
||||
|
||||
// 2. 메시지 추가 (허용 도메인 명시 + 응답 스타일 지시)
|
||||
const domainList = allowedDomains.join(', ');
|
||||
const instructions = `[시스템 지시]
|
||||
- 관리 가능 도메인: ${domainList}
|
||||
- 한국어로 질문하면 한국어로 답변하고, 가격은 원화(KRW)만 표시
|
||||
- 영어로 질문하면 영어로 답변하고, 가격은 달러(USD)로 표시
|
||||
- 가격 응답 시 불필요한 달러 환산 정보 생략
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
const result = await callNamecheapApi('list_domains', {}, allowedDomains, 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}`;
|
||||
}
|
||||
|
||||
[사용자 질문]
|
||||
${query}`;
|
||||
await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
role: 'user',
|
||||
content: instructions
|
||||
}),
|
||||
});
|
||||
case 'info': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`;
|
||||
}
|
||||
|
||||
// 3. Run 생성
|
||||
const runRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({ assistant_id: assistantId }),
|
||||
});
|
||||
if (!runRes.ok) return `Run 생성 실패 (${runRes.status})`;
|
||||
let run = await runRes.json() as { id: string; status: string; required_action?: any };
|
||||
case 'get_ns': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, 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}`;
|
||||
}
|
||||
|
||||
// 4. 완료까지 폴링 및 Function Calling 처리
|
||||
let maxPolls = 30; // 최대 15초
|
||||
while ((run.status === 'queued' || run.status === 'in_progress' || run.status === 'requires_action') && maxPolls > 0) {
|
||||
if (run.status === 'requires_action') {
|
||||
const toolCalls = run.required_action?.submit_tool_outputs?.tool_calls || [];
|
||||
const toolOutputs = [];
|
||||
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, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`;
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const funcName = toolCall.function.name;
|
||||
const funcArgs = JSON.parse(toolCall.function.arguments);
|
||||
const result = await callNamecheapApi(funcName, funcArgs, allowedDomains);
|
||||
toolOutputs.push({
|
||||
tool_call_id: toolCall.id,
|
||||
output: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
case 'check': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
const available = result[domain];
|
||||
if (available) {
|
||||
// 가격도 함께 조회
|
||||
const domainTld = domain.split('.').pop() || '';
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, telegramUserId, db, userId);
|
||||
const price = priceResult.krw || priceResult.register_krw;
|
||||
return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${price?.toLocaleString()}원/년\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`;
|
||||
}
|
||||
return `❌ ${domain}은 이미 등록된 도메인입니다.`;
|
||||
}
|
||||
|
||||
// Tool outputs 제출
|
||||
const submitRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}/submit_tool_outputs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({ tool_outputs: toolOutputs }),
|
||||
});
|
||||
run = await submitRes.json() as { id: string; status: string; required_action?: any };
|
||||
case 'whois': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, 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}`;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
maxPolls--;
|
||||
// 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 statusRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
run = await statusRes.json() as { id: string; status: string; required_action?: any };
|
||||
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 => 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 => 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();
|
||||
}
|
||||
|
||||
if (run.status === 'failed') return '도메인 에이전트 실행 실패';
|
||||
if (maxPolls === 0) return '응답 시간 초과. 다시 시도해주세요.';
|
||||
|
||||
// 5. 메시지 조회
|
||||
const messagesRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
const messages = await messagesRes.json() as { data: Array<{ role: string; content: Array<{ type: string; text?: { value: string } }> }> };
|
||||
const lastMessage = messages.data[0];
|
||||
|
||||
if (lastMessage?.content?.[0]?.type === 'text') {
|
||||
return lastMessage.content[0].text?.value || '응답 없음';
|
||||
case 'price': {
|
||||
// tld, domain, 또는 ".com" 형식 모두 지원
|
||||
let targetTld = tld || domain?.replace(/^\./, '').split('.').pop();
|
||||
if (!targetTld) return '🚫 TLD를 지정해주세요. (예: com, io, net)';
|
||||
const result = await callNamecheapApi('get_price', { tld: targetTld }, allowedDomains, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
// API 응답: { tld, usd, krw }
|
||||
const price = result.krw || result.register_krw;
|
||||
return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${price?.toLocaleString()}원/년`;
|
||||
}
|
||||
|
||||
return '도메인 에이전트 응답 없음';
|
||||
} catch (error) {
|
||||
return `도메인 에이전트 오류: ${String(error)}`;
|
||||
case 'register': {
|
||||
if (!domain) return '🚫 등록할 도메인을 지정해주세요.';
|
||||
if (!telegramUserId) return '🚫 도메인 등록에는 로그인이 필요합니다.';
|
||||
|
||||
// 1. 가용성 확인
|
||||
const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, 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, 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) {
|
||||
return `📋 도메인 등록 확인
|
||||
|
||||
• 도메인: ${domain}
|
||||
• 가격: ${price.toLocaleString()}원 (예치금에서 차감)
|
||||
• 현재 잔액: ${balance.toLocaleString()}원 ✓
|
||||
• 등록 기간: 1년
|
||||
|
||||
📌 등록자 정보
|
||||
서비스 기본 정보로 등록됩니다.
|
||||
(WHOIS Guard가 적용되어 개인정보는 비공개)
|
||||
|
||||
⚠️ 주의사항
|
||||
도메인 등록 후에는 취소 및 환불이 불가능합니다.
|
||||
|
||||
등록을 진행하시려면 '확인'이라고 입력해주세요.`;
|
||||
} else {
|
||||
const shortage = price - balance;
|
||||
return `📋 도메인 등록 확인
|
||||
|
||||
• 도메인: ${domain}
|
||||
• 가격: ${price.toLocaleString()}원
|
||||
• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족
|
||||
• 부족 금액: ${shortage.toLocaleString()}원
|
||||
|
||||
💳 입금 계좌
|
||||
하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||
입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return `🚫 알 수 없는 작업: ${action}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,13 +824,53 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
|
||||
case 'search_web': {
|
||||
// Brave Search API
|
||||
const query = args.query;
|
||||
let query = args.query;
|
||||
try {
|
||||
if (!env?.BRAVE_API_KEY) {
|
||||
return `🔍 검색 기능이 설정되지 않았습니다.`;
|
||||
}
|
||||
|
||||
// 한글이 포함된 경우 영문으로 번역 (기술 용어, 제품명 등)
|
||||
const hasKorean = /[가-힣]/.test(query);
|
||||
let translatedQuery = query;
|
||||
|
||||
if (hasKorean && env?.OPENAI_API_KEY) {
|
||||
try {
|
||||
const translateRes = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `사용자의 검색어를 영문으로 번역하세요.
|
||||
- 외래어/기술용어는 원래 영문 표기로 변환 (예: 판골린→Pangolin, 도커→Docker)
|
||||
- 일반 한국어는 영문으로 번역
|
||||
- 검색에 최적화된 키워드로 변환
|
||||
- 번역된 검색어만 출력, 설명 없이`
|
||||
},
|
||||
{ role: 'user', content: query }
|
||||
],
|
||||
max_tokens: 100,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
if (translateRes.ok) {
|
||||
const translateData = await translateRes.json() as any;
|
||||
translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query;
|
||||
console.log(`[search_web] 번역: "${query}" → "${translatedQuery}"`);
|
||||
}
|
||||
} catch {
|
||||
// 번역 실패 시 원본 사용
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`,
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -501,7 +893,12 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}`
|
||||
).join('\n\n');
|
||||
|
||||
return `🔍 검색 결과: ${query}\n\n${results}`;
|
||||
// 번역된 경우 원본 쿼리도 표시
|
||||
const queryDisplay = (hasKorean && translatedQuery !== query)
|
||||
? `${query} (→ ${translatedQuery})`
|
||||
: query;
|
||||
|
||||
return `🔍 검색 결과: ${queryDisplay}\n\n${results}`;
|
||||
} catch (error) {
|
||||
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
||||
}
|
||||
@@ -613,9 +1010,27 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
}
|
||||
}
|
||||
|
||||
case 'suggest_domains': {
|
||||
const keywords = args.keywords;
|
||||
console.log('[suggest_domains] 시작:', { keywords });
|
||||
|
||||
if (!env?.OPENAI_API_KEY) {
|
||||
return '🚫 도메인 추천 기능이 설정되지 않았습니다.';
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await suggestDomains(keywords, env.OPENAI_API_KEY);
|
||||
console.log('[suggest_domains] 완료:', result?.slice(0, 100));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[suggest_domains] 오류:', error);
|
||||
return `🚫 도메인 추천 오류: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
case 'manage_domain': {
|
||||
const query = args.query;
|
||||
console.log('[manage_domain] 시작:', { query, telegramUserId, hasDb: !!db });
|
||||
const { action, domain, nameservers, tld } = args;
|
||||
console.log('[manage_domain] 시작:', { action, domain, telegramUserId, hasDb: !!db });
|
||||
|
||||
// 소유권 검증 (DB 조회)
|
||||
if (!telegramUserId || !db) {
|
||||
@@ -624,15 +1039,16 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
}
|
||||
|
||||
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 }>();
|
||||
console.log('[manage_domain] user 조회 결과:', user);
|
||||
|
||||
if (!user) {
|
||||
return '🚫 도메인 관리 권한이 없습니다.';
|
||||
}
|
||||
userId = user.id;
|
||||
|
||||
// 사용자 소유 도메인 전체 목록 조회
|
||||
const domains = await db.prepare(
|
||||
@@ -640,32 +1056,26 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
).bind(user.id).all<{ domain: string }>();
|
||||
userDomains = domains.results?.map(d => d.domain) || [];
|
||||
console.log('[manage_domain] 소유 도메인:', userDomains);
|
||||
|
||||
if (userDomains.length === 0) {
|
||||
return '🚫 등록된 도메인이 없습니다. 먼저 도메인을 등록해주세요.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[manage_domain] DB 오류:', error);
|
||||
return '🚫 권한 확인 중 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
if (!env?.OPENAI_API_KEY || !env?.DOMAIN_AGENT_ID) {
|
||||
console.log('[manage_domain] env 설정 없음');
|
||||
return '🌐 도메인 관리 기능이 설정되지 않았습니다.';
|
||||
}
|
||||
// 코드로 직접 처리 (Agent 없이)
|
||||
try {
|
||||
console.log('[manage_domain] callDomainAgent 호출 시작');
|
||||
const result = await callDomainAgent(env.OPENAI_API_KEY, env.DOMAIN_AGENT_ID, query, userDomains);
|
||||
console.log('[manage_domain] callDomainAgent 완료:', result?.slice(0, 100));
|
||||
// Markdown → HTML 변환
|
||||
const htmlResult = result
|
||||
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||
return `🌐 ${htmlResult}`;
|
||||
const result = await executeDomainAction(
|
||||
action,
|
||||
{ domain, nameservers, tld },
|
||||
userDomains,
|
||||
telegramUserId,
|
||||
db,
|
||||
userId
|
||||
);
|
||||
console.log('[manage_domain] 완료:', result?.slice(0, 100));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.log('[manage_domain] callDomainAgent 오류:', error);
|
||||
return `🌐 도메인 관리 오류: ${String(error)}`;
|
||||
console.log('[manage_domain] 오류:', error);
|
||||
return `🚫 도메인 관리 오류: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +1088,7 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
async function callOpenAI(
|
||||
apiKey: string,
|
||||
messages: OpenAIMessage[],
|
||||
useTools: boolean = true
|
||||
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
||||
): Promise<OpenAIResponse> {
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -689,8 +1099,8 @@ async function callOpenAI(
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
tools: useTools ? tools : undefined,
|
||||
tool_choice: useTools ? 'auto' : undefined,
|
||||
tools: selectedTools?.length ? selectedTools : undefined,
|
||||
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
});
|
||||
@@ -725,10 +1135,16 @@ export async function generateOpenAIResponse(
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
|
||||
// 동적 도구 선택
|
||||
const selectedTools = selectToolsForMessage(userMessage);
|
||||
|
||||
// 첫 번째 호출
|
||||
let response = await callOpenAI(env.OPENAI_API_KEY, messages);
|
||||
let response = await callOpenAI(env.OPENAI_API_KEY, messages, selectedTools);
|
||||
let assistantMessage = response.choices[0].message;
|
||||
|
||||
console.log('[OpenAI] tool_calls:', assistantMessage.tool_calls ? JSON.stringify(assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments }))) : 'none');
|
||||
console.log('[OpenAI] content:', assistantMessage.content?.slice(0, 100));
|
||||
|
||||
// Function Calling 처리 (최대 3회 반복)
|
||||
let iterations = 0;
|
||||
while (assistantMessage.tool_calls && iterations < 3) {
|
||||
@@ -754,12 +1170,14 @@ export async function generateOpenAIResponse(
|
||||
});
|
||||
messages.push(...toolResults);
|
||||
|
||||
// 다시 호출
|
||||
response = await callOpenAI(env.OPENAI_API_KEY, messages, false);
|
||||
// 다시 호출 (도구 없이 응답 생성)
|
||||
response = await callOpenAI(env.OPENAI_API_KEY, messages, undefined);
|
||||
assistantMessage = response.choices[0].message;
|
||||
}
|
||||
|
||||
return assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
// 프로필 생성용 (도구 없이)
|
||||
@@ -774,7 +1192,7 @@ export async function generateProfileWithOpenAI(
|
||||
const response = await callOpenAI(
|
||||
env.OPENAI_API_KEY,
|
||||
[{ role: 'user', content: prompt }],
|
||||
false
|
||||
undefined // 도구 없이 호출
|
||||
);
|
||||
|
||||
return response.choices[0].message.content || '프로필 생성 실패';
|
||||
|
||||
Reference in New Issue
Block a user