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

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # 로컬 개발 (wrangler dev)
npm run deploy # Cloudflare Workers 배포
npm run db:init # D1 스키마 초기화 (production)
npm run db:init:local # D1 스키마 초기화 (local)
npm run tail # Workers 로그 스트리밍
```
**Secrets 설정**:
```bash
wrangler secret put BOT_TOKEN # Telegram Bot Token
wrangler secret put WEBHOOK_SECRET # Webhook 검증용
wrangler secret put OPENAI_API_KEY # OpenAI API 키
```
## Architecture
**Message Flow**:
```
Telegram Webhook → Security Validation → Command/Message Router
┌──────────────────────────┴──────────────────────────┐
↓ ↓
Command Handler AI Response Generator
(commands.ts) (openai-service.ts)
Function Calling
(weather, search, time, calc, docs)
Profile System
(summary-service.ts)
```
**Core Services**:
- `openai-service.ts` - GPT-4o-mini + Function Calling (6개 도구: weather, search, time, calculate, lookup_docs, manage_domain)
- `summary-service.ts` - 메시지 버퍼링 + 20개마다 프로필 추출 (슬라이딩 윈도우 3개)
- `security.ts` - Webhook 검증 (timing-safe comparison, timestamp validation, rate limiting 30req/min)
- `commands.ts` - 봇 명령어 핸들러 (/start, /help, /profile, /reset, /context, /stats, /debug)
**Data Layer** (D1 SQLite):
- `users` - telegram_id 기반 사용자
- `message_buffer` - 롤링 대화 기록
- `summaries` - 프로필 버전 관리 (generation 추적)
**AI Fallback**: OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환
## Key Patterns
**Function Calling**: `openai-service.ts``tools` 배열에 JSON Schema 정의, `executeFunctionCall()`에서 실행
**Profile System**: 매 20개 메시지마다 사용자 발언 분석 → 관심사/목표/맥락 추출 → summaries 테이블에 generation 증가하며 저장
**Context Enrichment**: `getConversationContext()`로 이전 프로필 + 최근 10개 메시지 조합하여 AI 프롬프트에 포함
## Configuration
`wrangler.toml` 환경변수:
- `SUMMARY_THRESHOLD`: 프로필 업데이트 주기 (기본 20)
- `MAX_SUMMARIES_PER_USER`: 유지할 프로필 버전 수 (기본 3)
- `DOMAIN_AGENT_ID`: OpenAI Assistant ID (도메인 관리 에이전트)
- `DOMAIN_OWNER_ID`: 도메인 관리 권한 Telegram ID (소유권 검증용)
## External Integrations
- **Context7 API**: `lookup_docs` 함수로 라이브러리 문서 조회
- **Domain Agent**: `manage_domain` 함수 → OpenAI Assistants API (`asst_MzPFKoqt7V4w6bc0UwcXU4ob`)
- **Namecheap API**: `https://namecheap-api.anvil.it.com` (Domain Agent 백엔드)
- **wttr.in**: 날씨 API
- **DuckDuckGo**: 웹 검색 API
- **Vault**: `vault.anvil.it.com`에서 API 키 중앙 관리

View File

@@ -20,8 +20,9 @@
- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원
- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회** 등 AI가 자동으로 도구 호출
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리** 등 AI가 자동으로 도구 호출
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
- **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동
- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억
- **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공
- **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환
@@ -34,6 +35,8 @@
| **D1** | SQLite 데이터베이스 |
| **OpenAI** | GPT-4o-mini + Function Calling |
| **Context7** | 라이브러리 문서 조회 API |
| **Domain Agent** | 도메인 관리 (OpenAI Assistants) |
| **Namecheap API** | 도메인 조회/관리 백엔드 |
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
---
@@ -57,12 +60,14 @@
│ (Function Call) │ 도구 호출 자동 판단
└──────────────────┘
┌───┴───┬───────┬───────┬───────┐
▼ ▼ ▼ ▼ ▼
[날씨] [검색] [시간] [계산] [문서] → 외부 API
│ │ │ │ │
│ │ │ │ └── Context7 API
└───┬───┴───────┴───────┴───────┘
┌───┴───┬───────┬───────┬───────┬───────
▼ ▼ ▼ ▼ ▼
[날씨] [검색] [시간] [계산] [문서] [도메인] → 외부 API
│ │ │ │ │
│ │ │ │ │ └── Domain Agent (Assistants API)
│ │ │ │ │ ↓
│ │ │ │ └── Context7 API Namecheap API
└───┬───┴───────┴───────┴───────┴───────────────────┘
┌──────────────────┐
│ 최종 응답 생성 │
@@ -120,6 +125,7 @@ OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출
| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 |
| **계산** | "123 * 456", "100의 20%" | 내장 |
| **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 |
| **도메인** | "도메인 목록", "anvil.it.com 네임서버" | Domain Agent (소유자 전용) |
### 동작 방식
@@ -297,6 +303,8 @@ binding = "AI"
SUMMARY_THRESHOLD = "20"
MAX_SUMMARIES_PER_USER = "3"
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
DOMAIN_OWNER_ID = "821596605"
[[d1_databases]]
binding = "DB"

View File

@@ -34,7 +34,20 @@ CREATE TABLE IF NOT EXISTS summaries (
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 도메인 소유권 테이블
CREATE TABLE IF NOT EXISTS user_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
domain TEXT UNIQUE NOT NULL,
verified INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id);
CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain);
CREATE INDEX IF NOT EXISTS idx_buffer_user ON message_buffer(user_id);
CREATE INDEX IF NOT EXISTS idx_buffer_chat ON message_buffer(user_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id);

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 {

View File

@@ -9,6 +9,8 @@ binding = "AI"
SUMMARY_THRESHOLD = "20"
MAX_SUMMARIES_PER_USER = "3"
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
DOMAIN_OWNER_ID = "821596605"
[[d1_databases]]
binding = "DB"
@@ -18,3 +20,5 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
# Secrets (wrangler secret put 으로 설정):
# - BOT_TOKEN: Telegram Bot Token
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
# - OPENAI_API_KEY: OpenAI API 키
# - NAMECHEAP_API_KEY: Namecheap API 서버 키 (Domain Agent용)