보안 개선: - API 키 하드코딩 제거 (NAMECHEAP_API_KEY_INTERNAL) - CORS 정책: * → hosting.anvil.it.com 제한 - /health 엔드포인트 DB 정보 노출 방지 - Rate Limiting 인메모리 Map → Cloudflare KV 전환 - 분산 환경 일관성 보장 - 재시작 후에도 유지 - 자동 만료 (TTL) 문서: - CLAUDE.md Security 섹션 추가 - KV Namespace 설정 가이드 추가 - 배포/마이그레이션 가이드 추가 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1356 lines
49 KiB
TypeScript
1356 lines
49 KiB
TypeScript
import type { Env } from './types';
|
||
import { executeDepositFunction, type DepositContext } from './deposit-agent';
|
||
|
||
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
||
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
|
||
|
||
interface OpenAIMessage {
|
||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||
content: string | null;
|
||
tool_calls?: ToolCall[];
|
||
tool_call_id?: string;
|
||
}
|
||
|
||
interface ToolCall {
|
||
id: string;
|
||
type: 'function';
|
||
function: {
|
||
name: string;
|
||
arguments: string;
|
||
};
|
||
}
|
||
|
||
interface OpenAIResponse {
|
||
choices: {
|
||
message: OpenAIMessage;
|
||
finish_reason: string;
|
||
}[];
|
||
}
|
||
|
||
// 사용 가능한 도구 정의
|
||
const tools = [
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'get_weather',
|
||
description: '특정 도시의 현재 날씨 정보를 가져옵니다',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
city: {
|
||
type: 'string',
|
||
description: '도시 이름 (예: Seoul, Tokyo, New York)',
|
||
},
|
||
},
|
||
required: ['city'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'search_web',
|
||
description: '웹에서 최신 정보를 검색합니다. 실시간 가격, 뉴스, 현재 날짜 이후 정보, 특정 사실 확인이 필요할 때 반드시 사용하세요. "비트코인 가격", "오늘 뉴스", "~란", "~뭐야" 등의 질문에 사용합니다.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
query: {
|
||
type: 'string',
|
||
description: '검색 쿼리',
|
||
},
|
||
},
|
||
required: ['query'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'get_current_time',
|
||
description: '현재 시간을 가져옵니다',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
timezone: {
|
||
type: 'string',
|
||
description: '타임존 (예: Asia/Seoul, UTC)',
|
||
},
|
||
},
|
||
required: [],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'calculate',
|
||
description: '수학 계산을 수행합니다',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
expression: {
|
||
type: 'string',
|
||
description: '계산할 수식 (예: 2+2, 100*5)',
|
||
},
|
||
},
|
||
required: ['expression'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'lookup_docs',
|
||
description: '프로그래밍 라이브러리의 공식 문서를 조회합니다. React, OpenAI, Cloudflare Workers 등의 최신 문서와 코드 예제를 검색할 수 있습니다.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
library: {
|
||
type: 'string',
|
||
description: '라이브러리 이름 (예: react, openai, cloudflare-workers, next.js)',
|
||
},
|
||
query: {
|
||
type: 'string',
|
||
description: '찾고 싶은 내용 (예: hooks 사용법, API 호출 방법)',
|
||
},
|
||
},
|
||
required: ['library', 'query'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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: 도메인 상세정보, 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'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'manage_deposit',
|
||
description: '예치금을 관리합니다. "입금", "충전", "잔액", "계좌", "계좌번호", "송금", "거래내역" 등의 키워드가 포함되면 반드시 이 도구를 사용하세요.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
action: {
|
||
type: 'string',
|
||
enum: ['balance', 'account', 'request', 'history', 'cancel', 'pending', 'confirm', 'reject'],
|
||
description: 'balance: 잔액 조회, account: 입금 계좌 안내, request: 입금 신고(충전 요청), history: 거래 내역, cancel: 입금 취소, pending: 대기 목록(관리자), confirm: 입금 확인(관리자), reject: 입금 거절(관리자)',
|
||
},
|
||
depositor_name: {
|
||
type: 'string',
|
||
description: '입금자명. request action에서 필수',
|
||
},
|
||
amount: {
|
||
type: 'number',
|
||
description: '금액. request action에서 필수. 자연어 금액은 숫자로 변환 (만원→10000, 5천원→5000)',
|
||
},
|
||
transaction_id: {
|
||
type: 'number',
|
||
description: '거래 ID. cancel, confirm, reject action에서 필수',
|
||
},
|
||
limit: {
|
||
type: 'number',
|
||
description: '조회 개수. history action에서 사용 (기본 10)',
|
||
},
|
||
},
|
||
required: ['action'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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, namecheapApiKey: string): Promise<string> {
|
||
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(OPENAI_API_URL, {
|
||
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[],
|
||
env?: Env,
|
||
telegramUserId?: string,
|
||
db?: D1Database,
|
||
userId?: number
|
||
): Promise<any> {
|
||
if (!env?.NAMECHEAP_API_KEY_INTERNAL) {
|
||
return { error: 'Namecheap API 키가 설정되지 않았습니다.' };
|
||
}
|
||
const apiKey = env.NAMECHEAP_API_KEY_INTERNAL;
|
||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
||
|
||
// 도메인 권한 체크 (쓰기 작업만)
|
||
// 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능
|
||
if (['set_nameservers', 'create_child_ns', 'delete_child_ns'].includes(funcName)) {
|
||
if (!allowedDomains.includes(funcArgs.domain)) {
|
||
return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` };
|
||
}
|
||
}
|
||
|
||
switch (funcName) {
|
||
case 'list_domains': {
|
||
const result = await fetch(`${apiUrl}/domains?page=${funcArgs.page || 1}&page_size=${funcArgs.page_size || 100}`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
}).then(r => r.json()) as any[];
|
||
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
|
||
const convertDate = (date: string) => {
|
||
const [month, day, year] = date.split('/');
|
||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||
};
|
||
// 허용된 도메인만 필터링, 날짜는 ISO 형식으로 변환
|
||
return result
|
||
.filter((d: any) => allowedDomains.includes(d.name))
|
||
.map((d: any) => ({
|
||
...d,
|
||
created: convertDate(d.created),
|
||
expires: convertDate(d.expires),
|
||
user: undefined, // 민감 정보 제거
|
||
}));
|
||
}
|
||
case 'get_domain_info': {
|
||
// 목록 API에서 더 많은 정보 조회 (단일 API는 정보 부족)
|
||
const domains = await fetch(`${apiUrl}/domains?page=1&page_size=100`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
}).then(r => r.json()) as any[];
|
||
const domainInfo = domains.find((d: any) => d.name === funcArgs.domain);
|
||
if (!domainInfo) {
|
||
return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` };
|
||
}
|
||
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
|
||
const convertDate = (date: string) => {
|
||
const [month, day, year] = date.split('/');
|
||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||
};
|
||
// 민감 정보 필터링 (user/owner 제거), 날짜는 ISO 형식으로 변환
|
||
return {
|
||
domain: domainInfo.name,
|
||
created: convertDate(domainInfo.created),
|
||
expires: convertDate(domainInfo.expires),
|
||
is_expired: domainInfo.is_expired,
|
||
auto_renew: domainInfo.auto_renew,
|
||
is_locked: domainInfo.is_locked,
|
||
whois_guard: domainInfo.whois_guard,
|
||
};
|
||
}
|
||
case 'get_nameservers':
|
||
return fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
}).then(r => r.json());
|
||
case 'set_nameservers': {
|
||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
|
||
method: 'PUT',
|
||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ domain: funcArgs.domain, nameservers: funcArgs.nameservers }),
|
||
});
|
||
const text = await res.text();
|
||
if (!res.ok) {
|
||
// Namecheap 에러 메시지 파싱
|
||
if (text.includes('subordinate hosts') || text.includes('Non existen')) {
|
||
return {
|
||
error: `네임서버 변경 실패: ${funcArgs.nameservers.join(', ')}는 등록되지 않은 네임서버입니다. 자기 도메인을 네임서버로 사용하려면 먼저 Namecheap에서 Child Nameserver(글루 레코드)를 IP 주소와 함께 등록해야 합니다.`
|
||
};
|
||
}
|
||
return { error: `네임서버 변경 실패: ${text}` };
|
||
}
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
return { success: true, message: text };
|
||
}
|
||
}
|
||
case 'create_child_ns': {
|
||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns`, {
|
||
method: 'POST',
|
||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ nameserver: funcArgs.nameserver, ip: funcArgs.ip }),
|
||
});
|
||
const data = await res.json() as any;
|
||
if (!res.ok) {
|
||
return { error: data.detail || `Child NS 생성 실패` };
|
||
}
|
||
return data;
|
||
}
|
||
case 'get_child_ns': {
|
||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns/${funcArgs.nameserver}`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
});
|
||
const data = await res.json() as any;
|
||
if (!res.ok) {
|
||
return { error: data.detail || `Child NS 조회 실패` };
|
||
}
|
||
return data;
|
||
}
|
||
case 'delete_child_ns': {
|
||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns/${funcArgs.nameserver}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-API-Key': apiKey },
|
||
});
|
||
const data = await res.json() as any;
|
||
if (!res.ok) {
|
||
return { error: data.detail || `Child NS 삭제 실패` };
|
||
}
|
||
return data;
|
||
}
|
||
case 'get_balance':
|
||
return fetch(`${apiUrl}/account/balance`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
}).then(r => r.json());
|
||
case 'get_price': {
|
||
const tld = funcArgs.tld?.replace(/^\./, ''); // .com → com
|
||
return fetch(`${apiUrl}/prices/${tld}`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
}).then(r => r.json());
|
||
}
|
||
case 'get_all_prices': {
|
||
return fetch(`${apiUrl}/prices`, {
|
||
headers: { 'X-API-Key': apiKey },
|
||
}).then(r => r.json());
|
||
}
|
||
case 'check_domains': {
|
||
return fetch(`${apiUrl}/domains/check`, {
|
||
method: 'POST',
|
||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ domains: funcArgs.domains }),
|
||
}).then(r => r.json());
|
||
}
|
||
case 'whois_lookup': {
|
||
// 자체 WHOIS API 서버 사용 (모든 TLD 지원)
|
||
const domain = funcArgs.domain;
|
||
try {
|
||
const whoisRes = await fetch(`https://whois-api-kappa-inoutercoms-projects.vercel.app/api/whois/${domain}`);
|
||
if (!whoisRes.ok) {
|
||
return { error: `WHOIS 조회 실패: HTTP ${whoisRes.status}` };
|
||
}
|
||
const whois = await whoisRes.json() as any;
|
||
|
||
if (whois.error) {
|
||
return { error: `WHOIS 조회 오류: ${whois.error}` };
|
||
}
|
||
|
||
// ccSLD WHOIS 미지원 처리
|
||
if (whois.whois_supported === false) {
|
||
return {
|
||
domain: whois.domain,
|
||
whois_supported: false,
|
||
ccSLD: whois.ccSLD,
|
||
message: whois.message_ko,
|
||
suggestion: whois.suggestion_ko,
|
||
};
|
||
}
|
||
|
||
// raw WHOIS 응답을 그대로 반환 (AI가 파싱)
|
||
return {
|
||
domain: whois.domain,
|
||
available: whois.available,
|
||
whois_server: whois.whois_server,
|
||
raw: whois.raw,
|
||
query_time_ms: whois.query_time_ms,
|
||
};
|
||
} catch (error) {
|
||
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}` };
|
||
}
|
||
}
|
||
|
||
// 도메인 작업 직접 실행 (Agent 없이 코드로 처리)
|
||
async function executeDomainAction(
|
||
action: string,
|
||
args: { domain?: string; nameservers?: string[]; tld?: string },
|
||
allowedDomains: string[],
|
||
env?: Env,
|
||
telegramUserId?: string,
|
||
db?: D1Database,
|
||
userId?: number
|
||
): Promise<string> {
|
||
const { domain, nameservers, tld } = args;
|
||
|
||
switch (action) {
|
||
case 'list': {
|
||
const result = await callNamecheapApi('list_domains', {}, allowedDomains, env, telegramUserId, db, userId);
|
||
if (result.error) return `🚫 ${result.error}`;
|
||
if (!result.length) return '📋 등록된 도메인이 없습니다.';
|
||
const list = result.map((d: any) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||
return `📋 내 도메인 목록 (${result.length}개)\n\n${list}`;
|
||
}
|
||
|
||
case 'info': {
|
||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||
const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, env, 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 ? '✅' : '❌'}`;
|
||
}
|
||
|
||
case 'get_ns': {
|
||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||
const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||
if (result.error) return `🚫 ${result.error}`;
|
||
const nsList = (result.nameservers || result).map((ns: string) => `• ${ns}`).join('\n');
|
||
return `🌐 ${domain} 네임서버\n\n${nsList}`;
|
||
}
|
||
|
||
case 'set_ns': {
|
||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||
if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.';
|
||
if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`;
|
||
const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId);
|
||
if (result.error) return `🚫 ${result.error}`;
|
||
return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`;
|
||
}
|
||
|
||
case 'check': {
|
||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
|
||
if (result.error) return `🚫 ${result.error}`;
|
||
const available = result[domain];
|
||
if (available) {
|
||
// 가격도 함께 조회
|
||
const domainTld = domain.split('.').pop() || '';
|
||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
|
||
const price = priceResult.krw || priceResult.register_krw;
|
||
return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${price?.toLocaleString()}원/년\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`;
|
||
}
|
||
return `❌ ${domain}은 이미 등록된 도메인입니다.`;
|
||
}
|
||
|
||
case 'whois': {
|
||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||
if (result.error) return `🚫 ${result.error}`;
|
||
|
||
// ccSLD WHOIS 미지원
|
||
if (result.whois_supported === false) {
|
||
return `🔍 ${domain} WHOIS\n\n⚠️ ${result.message}\n💡 ${result.suggestion}`;
|
||
}
|
||
|
||
// raw WHOIS 데이터에서 주요 정보 추출
|
||
const raw = result.raw || '';
|
||
const extractField = (patterns: RegExp[]): string => {
|
||
for (const pattern of patterns) {
|
||
const match = raw.match(pattern);
|
||
if (match) return match[1].trim();
|
||
}
|
||
return '-';
|
||
};
|
||
|
||
const created = extractField([
|
||
/Creation Date:\s*(.+)/i,
|
||
/Created Date:\s*(.+)/i,
|
||
/Registration Date:\s*(.+)/i,
|
||
/created:\s*(.+)/i,
|
||
]);
|
||
const expires = extractField([
|
||
/Registry Expiry Date:\s*(.+)/i,
|
||
/Expiration Date:\s*(.+)/i,
|
||
/Expiry Date:\s*(.+)/i,
|
||
/expires:\s*(.+)/i,
|
||
]);
|
||
const updated = extractField([
|
||
/Updated Date:\s*(.+)/i,
|
||
/Last Updated:\s*(.+)/i,
|
||
/modified:\s*(.+)/i,
|
||
]);
|
||
const registrar = extractField([
|
||
/Registrar:\s*(.+)/i,
|
||
/Sponsoring Registrar:\s*(.+)/i,
|
||
]);
|
||
const registrarUrl = extractField([
|
||
/Registrar URL:\s*(.+)/i,
|
||
]);
|
||
const registrant = extractField([
|
||
/Registrant Organization:\s*(.+)/i,
|
||
/Registrant Name:\s*(.+)/i,
|
||
/org:\s*(.+)/i,
|
||
]);
|
||
const registrantCountry = extractField([
|
||
/Registrant Country:\s*(.+)/i,
|
||
/Registrant State\/Province:\s*(.+)/i,
|
||
]);
|
||
const statusMatch = raw.match(/Domain Status:\s*(.+)/gi);
|
||
const statuses = statusMatch
|
||
? statusMatch.map(s => 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();
|
||
}
|
||
|
||
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, env, 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()}원/년`;
|
||
}
|
||
|
||
case 'cheapest': {
|
||
const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, env, telegramUserId, db, userId);
|
||
if (result.error) return `🚫 ${result.error}`;
|
||
|
||
// 가격 > 0인 TLD만 필터링, krw 기준 정렬
|
||
const sorted = (result as any[])
|
||
.filter((p: any) => p.krw > 0)
|
||
.sort((a: any, b: any) => a.krw - b.krw)
|
||
.slice(0, 15);
|
||
|
||
if (sorted.length === 0) {
|
||
return '🚫 TLD 가격 정보를 가져올 수 없습니다.';
|
||
}
|
||
|
||
const list = sorted.map((p: any, i: number) =>
|
||
`${i + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`
|
||
).join('\n');
|
||
|
||
return `💰 가장 저렴한 TLD TOP 15\n\n${list}\n\n💡 특정 TLD 가격은 ".com 가격" 형식으로 조회`;
|
||
}
|
||
|
||
case 'register': {
|
||
if (!domain) return '🚫 등록할 도메인을 지정해주세요.';
|
||
if (!telegramUserId) return '🚫 도메인 등록에는 로그인이 필요합니다.';
|
||
|
||
// 1. 가용성 확인
|
||
const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
|
||
if (checkResult.error) return `🚫 ${checkResult.error}`;
|
||
if (!checkResult[domain]) return `❌ ${domain}은 이미 등록된 도메인입니다.`;
|
||
|
||
// 2. 가격 조회
|
||
const domainTld = domain.split('.').pop() || '';
|
||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
|
||
if (priceResult.error) return `🚫 가격 조회 실패: ${priceResult.error}`;
|
||
const price = priceResult.krw || priceResult.register_krw;
|
||
|
||
// 3. 잔액 조회
|
||
let balance = 0;
|
||
if (db && userId) {
|
||
const balanceRow = await db.prepare('SELECT balance FROM user_deposits WHERE user_id = ?').bind(userId).first<{ balance: number }>();
|
||
balance = balanceRow?.balance || 0;
|
||
}
|
||
|
||
// 4. 확인 페이지 생성 (인라인 버튼 포함)
|
||
if (balance >= price) {
|
||
// 버튼 데이터를 특수 마커로 포함
|
||
const keyboardData = JSON.stringify({
|
||
type: 'domain_register',
|
||
domain: domain,
|
||
price: price
|
||
});
|
||
return `__KEYBOARD__${keyboardData}__END__
|
||
📋 <b>도메인 등록 확인</b>
|
||
|
||
• 도메인: <code>${domain}</code>
|
||
• 가격: ${price.toLocaleString()}원 (예치금에서 차감)
|
||
• 현재 잔액: ${balance.toLocaleString()}원 ✅
|
||
• 등록 기간: 1년
|
||
|
||
📌 <b>등록자 정보</b>
|
||
서비스 기본 정보로 등록됩니다.
|
||
(WHOIS Guard가 적용되어 개인정보는 비공개)
|
||
|
||
⚠️ <b>주의사항</b>
|
||
도메인 등록 후에는 취소 및 환불이 불가능합니다.`;
|
||
} else {
|
||
const shortage = price - balance;
|
||
return `📋 <b>도메인 등록 확인</b>
|
||
|
||
• 도메인: <code>${domain}</code>
|
||
• 가격: ${price.toLocaleString()}원
|
||
• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족
|
||
• 부족 금액: ${shortage.toLocaleString()}원
|
||
|
||
💳 <b>입금 계좌</b>
|
||
하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||
입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`;
|
||
}
|
||
}
|
||
|
||
default:
|
||
return `🚫 알 수 없는 작업: ${action}`;
|
||
}
|
||
}
|
||
|
||
// 예치금 결과 포맷팅 (고정 형식)
|
||
function formatDepositResult(action: string, result: any): string {
|
||
if (result.error) {
|
||
return `🚫 ${result.error}`;
|
||
}
|
||
|
||
switch (action) {
|
||
case 'balance':
|
||
return `💰 현재 잔액: ${result.formatted}`;
|
||
|
||
case 'account':
|
||
return `💳 입금 계좌 안내
|
||
|
||
• 은행: ${result.bank}
|
||
• 계좌번호: ${result.account}
|
||
• 예금주: ${result.holder}
|
||
|
||
📌 ${result.instruction}`;
|
||
|
||
case 'request':
|
||
if (result.auto_matched) {
|
||
return `✅ 입금 확인 완료!
|
||
|
||
• 입금액: ${result.amount.toLocaleString()}원
|
||
• 입금자: ${result.depositor_name}
|
||
• 현재 잔액: ${result.new_balance.toLocaleString()}원
|
||
|
||
${result.message}`;
|
||
} else {
|
||
return `📋 입금 요청 등록 (#${result.transaction_id})
|
||
|
||
• 입금액: ${result.amount.toLocaleString()}원
|
||
• 입금자: ${result.depositor_name}
|
||
|
||
💳 입금 계좌
|
||
${result.account_info.bank} ${result.account_info.account}
|
||
(${result.account_info.holder})
|
||
|
||
📌 ${result.message}`;
|
||
}
|
||
|
||
case 'history': {
|
||
if (result.message && !result.transactions?.length) {
|
||
return `📋 ${result.message}`;
|
||
}
|
||
const statusIcon = (s: string) => s === 'confirmed' ? '✓' : s === 'pending' ? '⏳' : '✗';
|
||
const typeLabel = (t: string) => t === 'deposit' ? '입금' : t === 'withdrawal' ? '출금' : t === 'refund' ? '환불' : t;
|
||
const txList = result.transactions.map((tx: any) => {
|
||
const date = tx.confirmed_at || tx.created_at;
|
||
const dateStr = date ? new Date(date).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }) : '';
|
||
const desc = tx.description ? ` - ${tx.description}` : '';
|
||
return `#${tx.id}: ${typeLabel(tx.type)} ${tx.amount.toLocaleString()}원 ${statusIcon(tx.status)} (${dateStr})${desc}`;
|
||
}).join('\n');
|
||
return `📋 거래 내역\n\n${txList}`;
|
||
}
|
||
|
||
case 'cancel':
|
||
return `✅ 거래 #${result.transaction_id} 취소 완료`;
|
||
|
||
case 'pending': {
|
||
if (result.message && !result.pending?.length) {
|
||
return `📋 ${result.message}`;
|
||
}
|
||
const pendingList = result.pending.map((p: any) =>
|
||
`#${p.id}: ${p.depositor_name} ${p.amount.toLocaleString()}원 (${p.user})`
|
||
).join('\n');
|
||
return `📋 대기 중인 입금 요청\n\n${pendingList}`;
|
||
}
|
||
|
||
case 'confirm':
|
||
return `✅ 입금 확인 완료 (#${result.transaction_id}, ${result.amount.toLocaleString()}원)`;
|
||
|
||
case 'reject':
|
||
return `❌ 입금 거절 완료 (#${result.transaction_id})`;
|
||
|
||
default:
|
||
return `💰 ${JSON.stringify(result)}`;
|
||
}
|
||
}
|
||
|
||
// 도구 실행
|
||
async function executeTool(name: string, args: Record<string, string>, env?: Env, telegramUserId?: string, db?: D1Database): Promise<string> {
|
||
switch (name) {
|
||
case 'get_weather': {
|
||
const city = args.city || 'Seoul';
|
||
try {
|
||
const response = await fetch(
|
||
`https://wttr.in/${encodeURIComponent(city)}?format=j1`
|
||
);
|
||
const data = await response.json() as any;
|
||
const current = data.current_condition[0];
|
||
return `🌤 ${city} 날씨
|
||
온도: ${current.temp_C}°C (체감 ${current.FeelsLikeC}°C)
|
||
상태: ${current.weatherDesc[0].value}
|
||
습도: ${current.humidity}%
|
||
풍속: ${current.windspeedKmph} km/h`;
|
||
} catch (error) {
|
||
return `날씨 정보를 가져올 수 없습니다: ${city}`;
|
||
}
|
||
}
|
||
|
||
case 'search_web': {
|
||
// Brave Search API
|
||
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(OPENAI_API_URL, {
|
||
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(translatedQuery)}&count=5`,
|
||
{
|
||
headers: {
|
||
'Accept': 'application/json',
|
||
'X-Subscription-Token': env.BRAVE_API_KEY,
|
||
},
|
||
}
|
||
);
|
||
if (!response.ok) {
|
||
return `🔍 검색 오류: ${response.status}`;
|
||
}
|
||
const data = await response.json() as any;
|
||
|
||
// Web 검색 결과 파싱
|
||
const webResults = data.web?.results || [];
|
||
if (webResults.length === 0) {
|
||
return `🔍 "${query}"에 대한 검색 결과가 없습니다.`;
|
||
}
|
||
|
||
const results = webResults.slice(0, 3).map((r: any, i: number) =>
|
||
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}`
|
||
).join('\n\n');
|
||
|
||
// 번역된 경우 원본 쿼리도 표시
|
||
const queryDisplay = (hasKorean && translatedQuery !== query)
|
||
? `${query} (→ ${translatedQuery})`
|
||
: query;
|
||
|
||
return `🔍 검색 결과: ${queryDisplay}\n\n${results}`;
|
||
} catch (error) {
|
||
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
||
}
|
||
}
|
||
|
||
case 'get_current_time': {
|
||
const timezone = args.timezone || 'Asia/Seoul';
|
||
try {
|
||
const now = new Date();
|
||
const formatted = now.toLocaleString('ko-KR', { timeZone: timezone });
|
||
return `🕐 현재 시간 (${timezone}): ${formatted}`;
|
||
} catch (error) {
|
||
return `시간 정보를 가져올 수 없습니다.`;
|
||
}
|
||
}
|
||
|
||
case 'calculate': {
|
||
const expression = args.expression;
|
||
try {
|
||
// 안전한 수식 계산 (기본 연산만)
|
||
const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, '');
|
||
const result = Function('"use strict"; return (' + sanitized + ')')();
|
||
return `🔢 계산 결과: ${expression} = ${result}`;
|
||
} catch (error) {
|
||
return `계산할 수 없는 수식입니다: ${expression}`;
|
||
}
|
||
}
|
||
|
||
case 'lookup_docs': {
|
||
const library = args.library;
|
||
const query = args.query;
|
||
try {
|
||
// Context7 REST API 직접 호출
|
||
// 1. 라이브러리 검색
|
||
const searchUrl = `https://context7.com/api/v2/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`;
|
||
const searchResponse = await fetch(searchUrl);
|
||
const searchData = await searchResponse.json() as any;
|
||
|
||
if (!searchData.libraries?.length) {
|
||
return `📚 "${library}" 라이브러리를 찾을 수 없습니다.`;
|
||
}
|
||
|
||
const libraryId = searchData.libraries[0].id;
|
||
|
||
// 2. 문서 조회
|
||
const docsUrl = `https://context7.com/api/v2/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
||
const docsResponse = await fetch(docsUrl);
|
||
const docsData = await docsResponse.json() as any;
|
||
|
||
if (docsData.error) {
|
||
return `📚 문서 조회 실패: ${docsData.message || docsData.error}`;
|
||
}
|
||
|
||
const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2);
|
||
return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`;
|
||
} catch (error) {
|
||
return `📚 문서 조회 중 오류: ${String(error)}`;
|
||
}
|
||
}
|
||
|
||
case 'manage_deposit': {
|
||
const { action, depositor_name, amount, transaction_id, limit } = args;
|
||
console.log('[manage_deposit] 시작:', { action, depositor_name, amount, telegramUserId });
|
||
|
||
if (!telegramUserId || !db) {
|
||
return '🚫 예치금 기능을 사용할 수 없습니다.';
|
||
}
|
||
|
||
// 사용자 조회
|
||
const user = await db.prepare(
|
||
'SELECT id FROM users WHERE telegram_id = ?'
|
||
).bind(telegramUserId).first<{ id: number }>();
|
||
|
||
if (!user) {
|
||
return '🚫 사용자 정보를 찾을 수 없습니다.';
|
||
}
|
||
|
||
const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID;
|
||
const context: DepositContext = {
|
||
userId: user.id,
|
||
telegramUserId,
|
||
isAdmin,
|
||
db,
|
||
};
|
||
|
||
// action → executeDepositFunction 매핑
|
||
const actionMap: Record<string, string> = {
|
||
balance: 'get_balance',
|
||
account: 'get_account_info',
|
||
request: 'request_deposit',
|
||
history: 'get_transactions',
|
||
cancel: 'cancel_transaction',
|
||
pending: 'get_pending_list',
|
||
confirm: 'confirm_deposit',
|
||
reject: 'reject_deposit',
|
||
};
|
||
|
||
const funcName = actionMap[action];
|
||
if (!funcName) {
|
||
return `🚫 알 수 없는 작업: ${action}`;
|
||
}
|
||
|
||
try {
|
||
const funcArgs: Record<string, any> = {};
|
||
if (depositor_name) funcArgs.depositor_name = depositor_name;
|
||
if (amount) funcArgs.amount = Number(amount);
|
||
if (transaction_id) funcArgs.transaction_id = Number(transaction_id);
|
||
if (limit) funcArgs.limit = Number(limit);
|
||
|
||
console.log('[manage_deposit] executeDepositFunction 호출:', funcName, funcArgs);
|
||
const result = await executeDepositFunction(funcName, funcArgs, context);
|
||
console.log('[manage_deposit] 결과:', JSON.stringify(result).slice(0, 200));
|
||
|
||
// 결과 포맷팅 (고정 형식)
|
||
return formatDepositResult(action, result);
|
||
} catch (error) {
|
||
console.error('[manage_deposit] 오류:', error);
|
||
return `🚫 예치금 처리 오류: ${String(error)}`;
|
||
}
|
||
}
|
||
|
||
case 'suggest_domains': {
|
||
const keywords = args.keywords;
|
||
console.log('[suggest_domains] 시작:', { keywords });
|
||
|
||
if (!env?.OPENAI_API_KEY) {
|
||
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (OPENAI_API_KEY 미설정)';
|
||
}
|
||
|
||
if (!env?.NAMECHEAP_API_KEY) {
|
||
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (NAMECHEAP_API_KEY 미설정)';
|
||
}
|
||
|
||
try {
|
||
const result = await suggestDomains(keywords, env.OPENAI_API_KEY, env.NAMECHEAP_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 { action, domain, nameservers, tld } = args;
|
||
console.log('[manage_domain] 시작:', { action, domain, telegramUserId, hasDb: !!db });
|
||
|
||
// 소유권 검증 (DB 조회)
|
||
if (!telegramUserId || !db) {
|
||
console.log('[manage_domain] 실패: telegramUserId 또는 db 없음');
|
||
return '🚫 도메인 관리 권한이 없습니다.';
|
||
}
|
||
|
||
let userDomains: string[] = [];
|
||
let userId: number | undefined;
|
||
try {
|
||
const user = await db.prepare(
|
||
'SELECT id FROM users WHERE telegram_id = ?'
|
||
).bind(telegramUserId).first<{ id: number }>();
|
||
|
||
if (!user) {
|
||
return '🚫 도메인 관리 권한이 없습니다.';
|
||
}
|
||
userId = user.id;
|
||
|
||
// 사용자 소유 도메인 전체 목록 조회
|
||
const domains = await db.prepare(
|
||
'SELECT domain FROM user_domains WHERE user_id = ? AND verified = 1'
|
||
).bind(user.id).all<{ domain: string }>();
|
||
userDomains = domains.results?.map(d => d.domain) || [];
|
||
console.log('[manage_domain] 소유 도메인:', userDomains);
|
||
} catch (error) {
|
||
console.log('[manage_domain] DB 오류:', error);
|
||
return '🚫 권한 확인 중 오류가 발생했습니다.';
|
||
}
|
||
|
||
// 코드로 직접 처리 (Agent 없이)
|
||
try {
|
||
const result = await executeDomainAction(
|
||
action,
|
||
{ domain, nameservers, tld },
|
||
userDomains,
|
||
env,
|
||
telegramUserId,
|
||
db,
|
||
userId
|
||
);
|
||
console.log('[manage_domain] 완료:', result?.slice(0, 100));
|
||
return result;
|
||
} catch (error) {
|
||
console.log('[manage_domain] 오류:', error);
|
||
return `🚫 도메인 관리 오류: ${String(error)}`;
|
||
}
|
||
}
|
||
|
||
default:
|
||
return `알 수 없는 도구: ${name}`;
|
||
}
|
||
}
|
||
|
||
// OpenAI API 호출
|
||
async function callOpenAI(
|
||
apiKey: string,
|
||
messages: OpenAIMessage[],
|
||
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
||
): Promise<OpenAIResponse> {
|
||
const response = await fetch(OPENAI_API_URL, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'gpt-4o-mini',
|
||
messages,
|
||
tools: selectedTools?.length ? selectedTools : undefined,
|
||
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
||
max_tokens: 1000,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.text();
|
||
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// 메인 응답 생성 함수
|
||
export async function generateOpenAIResponse(
|
||
env: Env,
|
||
userMessage: string,
|
||
systemPrompt: string,
|
||
recentContext: { role: 'user' | 'assistant'; content: string }[],
|
||
telegramUserId?: string,
|
||
db?: D1Database
|
||
): Promise<string> {
|
||
if (!env.OPENAI_API_KEY) {
|
||
throw new Error('OPENAI_API_KEY not configured');
|
||
}
|
||
|
||
const messages: OpenAIMessage[] = [
|
||
{ role: 'system', content: systemPrompt },
|
||
...recentContext.map((m) => ({
|
||
role: m.role as 'user' | 'assistant',
|
||
content: m.content,
|
||
})),
|
||
{ role: 'user', content: userMessage },
|
||
];
|
||
|
||
// 동적 도구 선택
|
||
const selectedTools = selectToolsForMessage(userMessage);
|
||
|
||
// 첫 번째 호출
|
||
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) {
|
||
iterations++;
|
||
|
||
// 도구 호출 결과 수집
|
||
const toolResults: OpenAIMessage[] = [];
|
||
for (const toolCall of assistantMessage.tool_calls) {
|
||
const args = JSON.parse(toolCall.function.arguments);
|
||
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
|
||
|
||
// __KEYBOARD__ 마커가 있으면 AI 재해석 없이 바로 반환 (버튼 보존)
|
||
if (result.includes('__KEYBOARD__')) {
|
||
return result;
|
||
}
|
||
|
||
toolResults.push({
|
||
role: 'tool',
|
||
tool_call_id: toolCall.id,
|
||
content: result,
|
||
});
|
||
}
|
||
|
||
// 대화에 추가
|
||
messages.push({
|
||
role: 'assistant',
|
||
content: assistantMessage.content,
|
||
tool_calls: assistantMessage.tool_calls,
|
||
});
|
||
messages.push(...toolResults);
|
||
|
||
// 다시 호출 (도구 없이 응답 생성)
|
||
response = await callOpenAI(env.OPENAI_API_KEY, messages, undefined);
|
||
assistantMessage = response.choices[0].message;
|
||
}
|
||
|
||
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||
|
||
return finalResponse;
|
||
}
|
||
|
||
// 프로필 생성용 (도구 없이)
|
||
export async function generateProfileWithOpenAI(
|
||
env: Env,
|
||
prompt: string
|
||
): Promise<string> {
|
||
if (!env.OPENAI_API_KEY) {
|
||
throw new Error('OPENAI_API_KEY not configured');
|
||
}
|
||
|
||
const response = await callOpenAI(
|
||
env.OPENAI_API_KEY,
|
||
[{ role: 'user', content: prompt }],
|
||
undefined // 도구 없이 호출
|
||
);
|
||
|
||
return response.choices[0].message.content || '프로필 생성 실패';
|
||
}
|