import type { Env } from './types'; import { callDepositAgent } from './deposit-agent'; 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: '도메인을 관리합니다. 도메인 목록 조회, 도메인 정보 확인, 네임서버 조회/변경, 도메인 가용성 확인, 계정 잔액 조회, TLD 가격 조회 등을 수행할 수 있습니다.', parameters: { type: 'object', properties: { query: { type: 'string', description: '도메인 관리 요청 (예: 도메인 목록 보여줘, anvil.it.com 네임서버 확인, example.com 등록 가능한지 확인)', }, }, required: ['query'], }, }, }, { type: 'function', function: { name: 'manage_deposit', description: '예치금을 관리합니다. 잔액 조회, 입금 계좌 안내, 입금 신고(충전 요청), 거래 내역 조회 등을 수행합니다. "입금", "충전", "잔액", "계좌", "계좌번호", "송금" 등의 키워드가 포함되면 반드시 이 도구를 사용하세요.', parameters: { type: 'object', properties: { query: { type: 'string', description: '예치금 관련 요청 (예: 잔액 확인, 홍길동 10000원 입금, 거래 내역, 입금 취소 #123)', }, }, required: ['query'], }, }, }, ]; // Namecheap API 호출 (allowedDomains로 필터링) async function callNamecheapApi(funcName: string, funcArgs: Record, allowedDomains: string[]): Promise { const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e'; 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 '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)}` }; } } default: return { error: `Unknown function: ${funcName}` }; } } // 도메인 에이전트 (Assistants API - 기존 Agent 활용) async function callDomainAgent( apiKey: string, assistantId: string, query: string, allowedDomains: string[] = [] ): Promise { 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 }; // 2. 메시지 추가 (허용 도메인 명시 + 응답 스타일 지시) const domainList = allowedDomains.join(', '); const instructions = `[시스템 지시] - 관리 가능 도메인: ${domainList} - 한국어로 질문하면 한국어로 답변하고, 가격은 원화(KRW)만 표시 - 영어로 질문하면 영어로 답변하고, 가격은 달러(USD)로 표시 - 가격 응답 시 불필요한 달러 환산 정보 생략 [사용자 질문] ${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 }), }); // 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 }; // 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 = []; 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), }); } // 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 }; } await new Promise(resolve => setTimeout(resolve, 500)); maxPolls--; 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 }; } 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 || '응답 없음'; } return '도메인 에이전트 응답 없음'; } catch (error) { return `도메인 에이전트 오류: ${String(error)}`; } } // 도구 실행 async function executeTool(name: string, args: Record, env?: Env, telegramUserId?: string, db?: D1Database): Promise { 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 const query = args.query; try { if (!env?.BRAVE_API_KEY) { return `🔍 검색 기능이 설정되지 않았습니다.`; } const response = await fetch( `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&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}. ${r.title}\n ${r.description}\n ${r.url}` ).join('\n\n'); return `🔍 검색 결과: ${query}\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 query = args.query; console.log('[manage_deposit] 시작:', { query, telegramUserId, hasDb: !!db }); 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; if (!env?.OPENAI_API_KEY || !env?.DEPOSIT_AGENT_ID) { console.log('[manage_deposit] DEPOSIT_AGENT_ID 미설정, 기본 응답'); return '💰 예치금 에이전트가 설정되지 않았습니다. 관리자에게 문의하세요.'; } try { console.log('[manage_deposit] callDepositAgent 호출'); const result = await callDepositAgent( env.OPENAI_API_KEY, env.DEPOSIT_AGENT_ID, query, { userId: user.id, telegramUserId, isAdmin, db, } ); console.log('[manage_deposit] callDepositAgent 완료:', result?.slice(0, 100)); // Markdown → HTML 변환 const htmlResult = result .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/`(.+?)`/g, '$1'); return `💰 ${htmlResult}`; } catch (error) { console.error('[manage_deposit] 오류:', error); return `💰 예치금 처리 오류: ${String(error)}`; } } case 'manage_domain': { const query = args.query; console.log('[manage_domain] 시작:', { query, telegramUserId, hasDb: !!db }); // 소유권 검증 (DB 조회) if (!telegramUserId || !db) { console.log('[manage_domain] 실패: telegramUserId 또는 db 없음'); return '🚫 도메인 관리 권한이 없습니다.'; } let userDomains: string[] = []; 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 '🚫 도메인 관리 권한이 없습니다.'; } // 사용자 소유 도메인 전체 목록 조회 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); 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 '🌐 도메인 관리 기능이 설정되지 않았습니다.'; } 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, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/`(.+?)`/g, '$1'); return `🌐 ${htmlResult}`; } catch (error) { console.log('[manage_domain] callDomainAgent 오류:', error); return `🌐 도메인 관리 오류: ${String(error)}`; } } default: return `알 수 없는 도구: ${name}`; } } // OpenAI API 호출 async function callOpenAI( apiKey: string, messages: OpenAIMessage[], useTools: boolean = true ): Promise { const response = 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, tools: useTools ? tools : undefined, tool_choice: useTools ? '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 { 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 }, ]; // 첫 번째 호출 let response = await callOpenAI(env.OPENAI_API_KEY, messages); let assistantMessage = response.choices[0].message; // 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); 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, false); assistantMessage = response.choices[0].message; } return assistantMessage.content || '응답을 생성할 수 없습니다.'; } // 프로필 생성용 (도구 없이) export async function generateProfileWithOpenAI( env: Env, prompt: string ): Promise { 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 }], false ); return response.choices[0].message.content || '프로필 생성 실패'; }