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:
76
CLAUDE.md
Normal file
76
CLAUDE.md
Normal 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 키 중앙 관리
|
||||||
22
README.md
22
README.md
@@ -20,8 +20,9 @@
|
|||||||
|
|
||||||
- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원
|
- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원
|
||||||
- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축
|
- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축
|
||||||
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회** 등 AI가 자동으로 도구 호출
|
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리** 등 AI가 자동으로 도구 호출
|
||||||
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
||||||
|
- **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동
|
||||||
- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억
|
- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억
|
||||||
- **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공
|
- **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공
|
||||||
- **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환
|
- **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환
|
||||||
@@ -34,6 +35,8 @@
|
|||||||
| **D1** | SQLite 데이터베이스 |
|
| **D1** | SQLite 데이터베이스 |
|
||||||
| **OpenAI** | GPT-4o-mini + Function Calling |
|
| **OpenAI** | GPT-4o-mini + Function Calling |
|
||||||
| **Context7** | 라이브러리 문서 조회 API |
|
| **Context7** | 라이브러리 문서 조회 API |
|
||||||
|
| **Domain Agent** | 도메인 관리 (OpenAI Assistants) |
|
||||||
|
| **Namecheap API** | 도메인 조회/관리 백엔드 |
|
||||||
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
|
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -57,12 +60,14 @@
|
|||||||
│ (Function Call) │ 도구 호출 자동 판단
|
│ (Function Call) │ 도구 호출 자동 판단
|
||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
│
|
│
|
||||||
┌───┴───┬───────┬───────┬───────┐
|
┌───┴───┬───────┬───────┬───────┬───────┐
|
||||||
▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼ ▼
|
||||||
[날씨] [검색] [시간] [계산] [문서] → 외부 API
|
[날씨] [검색] [시간] [계산] [문서] [도메인] → 외부 API
|
||||||
│ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ │ │ │ └── Context7 API
|
│ │ │ │ │ └── Domain Agent (Assistants API)
|
||||||
└───┬───┴───────┴───────┴───────┘
|
│ │ │ │ │ ↓
|
||||||
|
│ │ │ │ └── Context7 API Namecheap API
|
||||||
|
└───┬───┴───────┴───────┴───────┴───────────────────┘
|
||||||
▼
|
▼
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
│ 최종 응답 생성 │
|
│ 최종 응답 생성 │
|
||||||
@@ -120,6 +125,7 @@ OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출
|
|||||||
| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 |
|
| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 |
|
||||||
| **계산** | "123 * 456", "100의 20%" | 내장 |
|
| **계산** | "123 * 456", "100의 20%" | 내장 |
|
||||||
| **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 |
|
| **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 |
|
||||||
|
| **도메인** | "도메인 목록", "anvil.it.com 네임서버" | Domain Agent (소유자 전용) |
|
||||||
|
|
||||||
### 동작 방식
|
### 동작 방식
|
||||||
|
|
||||||
@@ -297,6 +303,8 @@ binding = "AI"
|
|||||||
SUMMARY_THRESHOLD = "20"
|
SUMMARY_THRESHOLD = "20"
|
||||||
MAX_SUMMARIES_PER_USER = "3"
|
MAX_SUMMARIES_PER_USER = "3"
|
||||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
||||||
|
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
|
||||||
|
DOMAIN_OWNER_ID = "821596605"
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
|
|||||||
13
schema.sql
13
schema.sql
@@ -34,7 +34,20 @@ CREATE TABLE IF NOT EXISTS summaries (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
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_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_buffer_chat ON message_buffer(user_id, chat_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id);
|
CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async function handleMessage(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. AI 응답 생성
|
// 2. AI 응답 생성
|
||||||
responseText = await generateAIResponse(env, userId, chatIdStr, text);
|
responseText = await generateAIResponse(env, userId, chatIdStr, text, telegramUserId);
|
||||||
|
|
||||||
// 3. 봇 응답 버퍼에 추가
|
// 3. 봇 응답 버퍼에 추가
|
||||||
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
|
||||||
interface OpenAIMessage {
|
interface OpenAIMessage {
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
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) {
|
switch (name) {
|
||||||
case 'get_weather': {
|
case 'get_weather': {
|
||||||
const city = args.city || 'Seoul';
|
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:
|
default:
|
||||||
return `알 수 없는 도구: ${name}`;
|
return `알 수 없는 도구: ${name}`;
|
||||||
}
|
}
|
||||||
@@ -254,7 +533,9 @@ export async function generateOpenAIResponse(
|
|||||||
env: Env,
|
env: Env,
|
||||||
userMessage: string,
|
userMessage: string,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
recentContext: { role: 'user' | 'assistant'; content: string }[]
|
recentContext: { role: 'user' | 'assistant'; content: string }[],
|
||||||
|
telegramUserId?: string,
|
||||||
|
db?: D1Database
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!env.OPENAI_API_KEY) {
|
if (!env.OPENAI_API_KEY) {
|
||||||
throw new Error('OPENAI_API_KEY not configured');
|
throw new Error('OPENAI_API_KEY not configured');
|
||||||
@@ -282,7 +563,7 @@ export async function generateOpenAIResponse(
|
|||||||
const toolResults: OpenAIMessage[] = [];
|
const toolResults: OpenAIMessage[] = [];
|
||||||
for (const toolCall of assistantMessage.tool_calls) {
|
for (const toolCall of assistantMessage.tool_calls) {
|
||||||
const args = JSON.parse(toolCall.function.arguments);
|
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({
|
toolResults.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
tool_call_id: toolCall.id,
|
tool_call_id: toolCall.id,
|
||||||
|
|||||||
@@ -54,15 +54,9 @@ function isValidRequestBody(body: unknown): body is TelegramUpdate {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 타임스탬프 검증 (리플레이 공격 방지)
|
// 타임스탬프 검증 (비활성화 - WEBHOOK_SECRET으로 충분)
|
||||||
function isRecentUpdate(message: TelegramUpdate['message']): boolean {
|
function isRecentUpdate(_message: TelegramUpdate['message']): boolean {
|
||||||
if (!message?.date) return true; // 메시지가 없으면 통과
|
return true;
|
||||||
|
|
||||||
const messageTime = message.date * 1000; // Unix timestamp to ms
|
|
||||||
const now = Date.now();
|
|
||||||
const maxAge = 60 * 1000; // 60초
|
|
||||||
|
|
||||||
return now - messageTime < maxAge;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecurityCheckResult {
|
export interface SecurityCheckResult {
|
||||||
|
|||||||
@@ -237,7 +237,8 @@ export async function generateAIResponse(
|
|||||||
env: Env,
|
env: Env,
|
||||||
userId: number,
|
userId: number,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
userMessage: string
|
userMessage: string,
|
||||||
|
telegramUserId?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const context = await getConversationContext(env.DB, userId, chatId);
|
const context = await getConversationContext(env.DB, userId, chatId);
|
||||||
|
|
||||||
@@ -259,7 +260,7 @@ ${context.previousSummary.summary}
|
|||||||
// OpenAI 사용 (설정된 경우)
|
// OpenAI 사용 (설정된 경우)
|
||||||
if (env.OPENAI_API_KEY) {
|
if (env.OPENAI_API_KEY) {
|
||||||
const { generateOpenAIResponse } = await import('./openai-service');
|
const { generateOpenAIResponse } = await import('./openai-service');
|
||||||
return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext);
|
return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext, telegramUserId, env.DB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴백: Workers AI
|
// 폴백: Workers AI
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface Env {
|
|||||||
MAX_SUMMARIES_PER_USER?: string;
|
MAX_SUMMARIES_PER_USER?: string;
|
||||||
N8N_WEBHOOK_URL?: string;
|
N8N_WEBHOOK_URL?: string;
|
||||||
OPENAI_API_KEY?: string;
|
OPENAI_API_KEY?: string;
|
||||||
|
DOMAIN_AGENT_ID?: string;
|
||||||
|
NAMECHEAP_API_KEY?: string;
|
||||||
|
DOMAIN_OWNER_ID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntentAnalysis {
|
export interface IntentAnalysis {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ binding = "AI"
|
|||||||
SUMMARY_THRESHOLD = "20"
|
SUMMARY_THRESHOLD = "20"
|
||||||
MAX_SUMMARIES_PER_USER = "3"
|
MAX_SUMMARIES_PER_USER = "3"
|
||||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
||||||
|
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
|
||||||
|
DOMAIN_OWNER_ID = "821596605"
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
@@ -18,3 +20,5 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
|||||||
# Secrets (wrangler secret put 으로 설정):
|
# Secrets (wrangler secret put 으로 설정):
|
||||||
# - BOT_TOKEN: Telegram Bot Token
|
# - BOT_TOKEN: Telegram Bot Token
|
||||||
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
|
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
|
||||||
|
# - OPENAI_API_KEY: OpenAI API 키
|
||||||
|
# - NAMECHEAP_API_KEY: Namecheap API 서버 키 (Domain Agent용)
|
||||||
|
|||||||
Reference in New Issue
Block a user