diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..046235e --- /dev/null +++ b/CLAUDE.md @@ -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 키 중앙 관리 diff --git a/README.md b/README.md index ad31f09..9186e9a 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/schema.sql b/schema.sql index d464e79..aebbb51 100644 --- a/schema.sql +++ b/schema.sql @@ -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); diff --git a/src/index.ts b/src/index.ts index 6cfcb3e..cb63cd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/openai-service.ts b/src/openai-service.ts index 3993045..b3a2cb9 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -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, allowedDomains: string[]): Promise { + 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 { + 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): Promise { +async function executeTool(name: string, args: Record, env?: Env, telegramUserId?: string, db?: D1Database): Promise { switch (name) { case 'get_weather': { const city = args.city || 'Seoul'; @@ -215,6 +438,62 @@ async function executeTool(name: string, args: Record): 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, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1'); + 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 { 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, diff --git a/src/security.ts b/src/security.ts index 683d813..a2420df 100644 --- a/src/security.ts +++ b/src/security.ts @@ -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 { diff --git a/src/summary-service.ts b/src/summary-service.ts index e9e4598..41f4614 100644 --- a/src/summary-service.ts +++ b/src/summary-service.ts @@ -237,7 +237,8 @@ export async function generateAIResponse( env: Env, userId: number, chatId: string, - userMessage: string + userMessage: string, + telegramUserId?: string ): Promise { 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 diff --git a/src/types.ts b/src/types.ts index 8dc48bc..130ef18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/wrangler.toml b/wrangler.toml index 6a6c189..0cce3f7 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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용)