## 검색 API 교체 - DuckDuckGo Instant Answer API → Brave Search API - 실제 웹 검색 결과 반환 (제목, 설명, URL) - Vault에 API 키 저장 (secret/brave-search) - Free AI 플랜: 2,000 queries/월 ## 시스템 프롬프트 개선 - 검색 도구 사용 조건 명시 (최신 정보, 실시간 가격 등) - 도구 description에 트리거 키워드 추가 ## 입금 알림 개선 - 자동 매칭 성공 시 사용자에게 Telegram 알림 전송 - tryAutoMatch() 반환값에 userId, amount 추가 ## 문서 업데이트 - Function Calling Tools 테이블에 트리거 키워드 컬럼 추가 - AI 시스템 프롬프트 섹션 추가 - Deposit Agent 프롬프트 수정 방법 문서화 - 자동 알림 시스템 섹션 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
782 lines
26 KiB
TypeScript
782 lines
26 KiB
TypeScript
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<string, any>, allowedDomains: string[]): Promise<any> {
|
|
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<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 };
|
|
|
|
// 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<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
|
|
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}. <b>${r.title}</b>\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, '<b>$1</b>')
|
|
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
|
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, '<b>$1</b>')
|
|
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
|
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<OpenAIResponse> {
|
|
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<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 },
|
|
];
|
|
|
|
// 첫 번째 호출
|
|
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<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 }],
|
|
false
|
|
);
|
|
|
|
return response.choices[0].message.content || '프로필 생성 실패';
|
|
}
|