feat: 도메인 관리 기능 추가 (Domain Agent 연동)

- manage_domain Function Calling 도구 추가
- OpenAI Assistants API 기반 Domain Agent 연동
- Namecheap API 호출 (도메인 목록, 네임서버 관리 등)
- user_domains 테이블로 사용자별 도메인 권한 관리
- 타임스탬프 검증 비활성화 (WEBHOOK_SECRET으로 충분)
- CLAUDE.md 프로젝트 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-16 08:50:16 +09:00
parent 2694531076
commit 8b2ccf05b5
9 changed files with 403 additions and 23 deletions

View File

@@ -94,7 +94,7 @@ async function handleMessage(
try {
// 2. AI 응답 생성
responseText = await generateAIResponse(env, userId, chatIdStr, text);
responseText = await generateAIResponse(env, userId, chatIdStr, text, telegramUserId);
// 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);

View File

@@ -1,4 +1,4 @@
import { Env } from './types';
import type { Env } from './types';
interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
@@ -114,10 +114,233 @@ const tools = [
},
},
},
{
type: 'function',
function: {
name: 'manage_domain',
description: '도메인을 관리합니다. 도메인 목록 조회, 도메인 정보 확인, 네임서버 조회/변경, 도메인 가용성 확인, 계정 잔액 조회, TLD 가격 조회 등을 수행할 수 있습니다.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '도메인 관리 요청 (예: 도메인 목록 보여줘, anvil.it.com 네임서버 확인, example.com 등록 가능한지 확인)',
},
},
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';
// 도메인 권한 체크
if (['get_domain_info', 'get_nameservers', 'set_nameservers', 'create_child_ns', 'get_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[];
// 허용된 도메인만 필터링
return result.filter((d: any) => allowedDomains.includes(d.name));
}
case 'get_domain_info':
return fetch(`${apiUrl}/domains/${funcArgs.domain}`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
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());
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(', ');
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: `[관리 가능 도메인: ${domainList}]\n\n${query}`
}),
});
// 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>): Promise<string> {
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';
@@ -215,6 +438,62 @@ async function executeTool(name: string, args: Record<string, string>): Promise<
}
}
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}`;
}
@@ -254,7 +533,9 @@ export async function generateOpenAIResponse(
env: Env,
userMessage: string,
systemPrompt: string,
recentContext: { role: 'user' | 'assistant'; content: 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');
@@ -282,7 +563,7 @@ export async function generateOpenAIResponse(
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);
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
toolResults.push({
role: 'tool',
tool_call_id: toolCall.id,

View File

@@ -54,15 +54,9 @@ function isValidRequestBody(body: unknown): body is TelegramUpdate {
);
}
// 타임스탬프 검증 (리플레이 공격 방지)
function isRecentUpdate(message: TelegramUpdate['message']): boolean {
if (!message?.date) return true; // 메시지가 없으면 통과
const messageTime = message.date * 1000; // Unix timestamp to ms
const now = Date.now();
const maxAge = 60 * 1000; // 60초
return now - messageTime < maxAge;
// 타임스탬프 검증 (비활성화 - WEBHOOK_SECRET으로 충분)
function isRecentUpdate(_message: TelegramUpdate['message']): boolean {
return true;
}
export interface SecurityCheckResult {

View File

@@ -237,7 +237,8 @@ export async function generateAIResponse(
env: Env,
userId: number,
chatId: string,
userMessage: string
userMessage: string,
telegramUserId?: string
): Promise<string> {
const context = await getConversationContext(env.DB, userId, chatId);
@@ -259,7 +260,7 @@ ${context.previousSummary.summary}
// OpenAI 사용 (설정된 경우)
if (env.OPENAI_API_KEY) {
const { generateOpenAIResponse } = await import('./openai-service');
return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext);
return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext, telegramUserId, env.DB);
}
// 폴백: Workers AI

View File

@@ -7,6 +7,9 @@ export interface Env {
MAX_SUMMARIES_PER_USER?: string;
N8N_WEBHOOK_URL?: string;
OPENAI_API_KEY?: string;
DOMAIN_AGENT_ID?: string;
NAMECHEAP_API_KEY?: string;
DOMAIN_OWNER_ID?: string;
}
export interface IntentAnalysis {