feat: 도메인 시스템 개선 + 검색 한글→영문 번역
주요 변경: - Domain Agent 제거, 코드 직접 처리로 전환 - suggest_domains: 등록 가능 도메인만 표시, 10개 미만 시 재시도 - search_web: 한글 검색어 자동 영문 번역 (GPT-4o-mini) - WHOIS: raw 데이터 파싱으로 상세 정보 추출 - 가격 조회: API 필드명 수정 (register_krw → krw) - 동적 도구 로딩 시스템 추가 - 문서 정리 및 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
199
CLAUDE.md
199
CLAUDE.md
@@ -181,8 +181,9 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
Command Handler AI Response Generator
|
Command Handler AI Response Generator
|
||||||
(commands.ts) (openai-service.ts)
|
(commands.ts) (openai-service.ts)
|
||||||
↓
|
↓
|
||||||
Function Calling
|
Function Calling (8개)
|
||||||
(weather, search, time, calc, docs)
|
(weather, search, time, calc, docs,
|
||||||
|
domain, suggest_domains, deposit)
|
||||||
↓
|
↓
|
||||||
Profile System
|
Profile System
|
||||||
(summary-service.ts)
|
(summary-service.ts)
|
||||||
@@ -199,15 +200,16 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
| `commands.ts` | 봇 명령어 | `handleCommand()` |
|
| `commands.ts` | 봇 명령어 | `handleCommand()` |
|
||||||
| `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` |
|
| `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` |
|
||||||
|
|
||||||
**Function Calling Tools (7개):**
|
**Function Calling Tools (8개):**
|
||||||
| 도구 | 함수명 | 외부 API | 트리거 키워드 |
|
| 도구 | 함수명 | 외부 API | 트리거 키워드 |
|
||||||
|------|--------|----------|---------------|
|
|------|--------|----------|---------------|
|
||||||
| 날씨 | `get_weather` | wttr.in | 날씨 |
|
| 날씨 | `get_weather` | wttr.in | 날씨 |
|
||||||
| 검색 | `web_search` | Brave Search | ~란, ~뭐야 |
|
| 검색 | `search_web` | Brave Search | ~란, ~뭐야 (한글→영문 자동 번역) |
|
||||||
| 시간 | `get_current_time` | 내장 | 몇 시, 시간 |
|
| 시간 | `get_current_time` | 내장 | 몇 시, 시간 |
|
||||||
| 계산 | `calculate` | 내장 | 계산, +, -, *, / |
|
| 계산 | `calculate` | 내장 | 계산, +, -, *, / |
|
||||||
| 문서 | `lookup_docs` | Context7 | 문서, 사용법, API |
|
| 문서 | `lookup_docs` | Context7 | 문서, 사용법, API |
|
||||||
| 도메인 | `manage_domain` | Domain Agent → Namecheap | 도메인, 네임서버, WHOIS |
|
| 도메인 | `manage_domain` | 코드 직접 처리 → Namecheap | 도메인, 네임서버, WHOIS |
|
||||||
|
| 도메인 추천 | `suggest_domains` | GPT + Namecheap | **도메인 추천, 도메인 제안, 도메인 아이디어** |
|
||||||
| 예치금 | `manage_deposit` | Deposit Agent → D1 | **입금, 충전, 잔액, 계좌, 송금** |
|
| 예치금 | `manage_deposit` | Deposit Agent → D1 | **입금, 충전, 잔액, 계좌, 송금** |
|
||||||
|
|
||||||
**Data Layer (D1 SQLite):**
|
**Data Layer (D1 SQLite):**
|
||||||
@@ -219,9 +221,31 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
| `user_deposits` | 예치금 계정 | user_id, balance |
|
| `user_deposits` | 예치금 계정 | user_id, balance |
|
||||||
| `deposit_transactions` | 거래 내역 | user_id, amount, status |
|
| `deposit_transactions` | 거래 내역 | user_id, amount, status |
|
||||||
| `bank_notifications` | SMS 파싱 | depositor_name, amount, bank |
|
| `bank_notifications` | SMS 파싱 | depositor_name, amount, bank |
|
||||||
|
| `user_domains` | 도메인 소유권 | user_id, domain, verified (등록 시 자동 추가) |
|
||||||
|
|
||||||
**AI Fallback:** OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환
|
**AI Fallback:** OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환
|
||||||
|
|
||||||
|
### 동적 도구 로딩
|
||||||
|
|
||||||
|
**목적:** 토큰 절약 + AI 선택 정확도 향상
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 메시지 → 키워드 패턴 매칭 → 관련 도구만 선택 → AI 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
**카테고리 분류:**
|
||||||
|
| 카테고리 | 도구 | 감지 패턴 |
|
||||||
|
|----------|------|-----------|
|
||||||
|
| domain | manage_domain, suggest_domains | 도메인, 네임서버, whois, .com |
|
||||||
|
| deposit | manage_deposit | 입금, 충전, 잔액, 계좌 |
|
||||||
|
| weather | get_weather | 날씨, 기온, 비, 눈 |
|
||||||
|
| search | search_web, lookup_docs | 검색, 찾아, 뭐야, 가격 |
|
||||||
|
| utility | get_current_time, calculate | (항상 포함) |
|
||||||
|
|
||||||
|
**폴백:** 패턴 매칭 없으면 전체 도구 사용
|
||||||
|
|
||||||
|
**로그:** `[ToolSelector] 카테고리: domain, utility / 선택된 도구: manage_domain, ...`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
@@ -246,41 +270,74 @@ case 'new_tool':
|
|||||||
return await executeNewTool(args);
|
return await executeNewTool(args);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 프로필 시스템 흐름
|
### 프로필 시스템 흐름 (3개 요약 통합 방식)
|
||||||
```
|
```
|
||||||
메시지 수신 → message_buffer 저장 (최대 19개)
|
메시지 수신 → message_buffer 저장 (최대 19개)
|
||||||
↓ 20개 도달
|
↓ 20개 도달
|
||||||
사용자 발언만 추출 → OpenAI 분석
|
┌──────────────┴──────────────┐
|
||||||
|
↓ ↓
|
||||||
|
기존 요약 3개 조회 사용자 발언만 추출
|
||||||
|
↓ ↓
|
||||||
|
└──────────→ OpenAI 통합 분석 ←┘
|
||||||
↓
|
↓
|
||||||
summaries 테이블 저장 (generation++)
|
summaries 테이블 저장 (generation++)
|
||||||
↓
|
↓
|
||||||
구버전 삭제 (최근 3개만 유지)
|
구버전 삭제 (최근 3개만 유지)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**통합 분석 방식:**
|
||||||
|
- 기존: 최신 요약 1개만 참조하여 업데이트
|
||||||
|
- 변경: **모든 요약 (최대 3개) + 새 메시지 → AI가 통합 분석**
|
||||||
|
|
||||||
### Context Enrichment
|
### Context Enrichment
|
||||||
```typescript
|
```typescript
|
||||||
// getConversationContext() 반환값 구조
|
// getConversationContext() 반환값 구조
|
||||||
{
|
{
|
||||||
profile: "이전 프로필 요약",
|
previousSummary: Summary | null, // 최신 요약 (호환성)
|
||||||
recentMessages: [ /* 최근 10개 */ ]
|
summaries: Summary[], // 전체 요약 (최대 3개, 최신순)
|
||||||
|
recentMessages: BufferedMessage[],
|
||||||
|
totalMessages: number,
|
||||||
}
|
}
|
||||||
// → AI 프롬프트의 system 메시지에 포함
|
```
|
||||||
|
|
||||||
|
**시스템 프롬프트에 통합:**
|
||||||
|
```
|
||||||
|
## 사용자 프로필 (3개 버전 통합)
|
||||||
|
[v1] 초기 프로필 내용...
|
||||||
|
[v2] 업데이트된 프로필...
|
||||||
|
[v3] 최신 프로필...
|
||||||
|
|
||||||
|
최신 버전을 우선시하되, 이전 버전 맥락도 고려
|
||||||
```
|
```
|
||||||
|
|
||||||
### AI 시스템 프롬프트 (`summary-service.ts`)
|
### AI 시스템 프롬프트 (`summary-service.ts`)
|
||||||
```
|
```
|
||||||
- 날씨, 시간, 계산, 검색 등의 요청은 제공된 도구를 사용하세요.
|
- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요.
|
||||||
|
- 최신 정보, 실시간 데이터, 현재 가격, 뉴스 등은 search_web 도구로 검색하세요.
|
||||||
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요.
|
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요.
|
||||||
금액 제한이나 규칙을 직접 판단하지 마세요.
|
- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요.
|
||||||
- 도메인 관련 요청은 반드시 manage_domain 도구를 사용하세요.
|
- 기타 도메인 관련 요청(조회, 등록, 네임서버 등)은 manage_domain 도구를 사용하세요.
|
||||||
- manage_deposit, manage_domain 도구 결과는 그대로 전달하세요.
|
- manage_deposit, manage_domain, suggest_domains 도구 결과는 그대로 전달하세요.
|
||||||
추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**중요:** 메인 AI가 도구를 호출하지 않고 직접 답변하는 경우:
|
**중요:** 메인 AI가 도구를 호출하지 않고 직접 답변하는 경우:
|
||||||
1. 시스템 프롬프트에 해당 키워드 추가 (`summary-service.ts:252-254`)
|
1. 시스템 프롬프트에 해당 키워드 추가 (`summary-service.ts:252-254`)
|
||||||
2. 도구 description에 키워드 명시 (`openai-service.ts` tools 배열)
|
2. 도구 description에 키워드 명시 (`openai-service.ts` tools 배열)
|
||||||
|
|
||||||
|
### 검색 한글→영문 자동 번역
|
||||||
|
|
||||||
|
```
|
||||||
|
"판골린 VPN" → GPT-4o-mini 번역 → "Pangolin VPN" → Brave Search
|
||||||
|
```
|
||||||
|
|
||||||
|
**동작:**
|
||||||
|
- 한글 포함 검색어 감지 (`/[가-힣]/`)
|
||||||
|
- GPT-4o-mini로 영문 번역 (외래어/기술용어 원어 복원)
|
||||||
|
- 번역된 쿼리로 검색
|
||||||
|
- 결과에 원본+번역 표시: `검색 결과: 판골린 VPN (→ Pangolin VPN)`
|
||||||
|
|
||||||
|
**로그:** `[search_web] 번역: "판골린 VPN" → "Pangolin VPN"`
|
||||||
|
|
||||||
### Deposit Agent 프롬프트 수정 방법
|
### Deposit Agent 프롬프트 수정 방법
|
||||||
```bash
|
```bash
|
||||||
# Vault에서 API 키 조회
|
# Vault에서 API 키 조회
|
||||||
@@ -302,12 +359,12 @@ curl -X POST 'https://api.openai.com/v1/assistants/asst_XMoVGU7ZwRpUPI6PHGvRNm8E
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `SUMMARY_THRESHOLD` | 20 | 프로필 업데이트 주기 (메시지 수) |
|
| `SUMMARY_THRESHOLD` | 20 | 프로필 업데이트 주기 (메시지 수) |
|
||||||
| `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 |
|
| `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 |
|
||||||
| `DOMAIN_AGENT_ID` | - | 도메인 관리 Assistant ID |
|
|
||||||
| `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID |
|
| `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID |
|
||||||
| `DEPOSIT_AGENT_ID` | - | 예치금 관리 Assistant ID |
|
| `DEPOSIT_AGENT_ID` | - | 예치금 관리 Assistant ID |
|
||||||
| `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID |
|
| `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID |
|
||||||
| `BANK_API_SECRET` | - | 입금 알림 API 인증 키 (wrangler secret) |
|
| `BANK_API_SECRET` | - | 입금 알림 API 인증 키 (wrangler secret) |
|
||||||
| `BRAVE_API_KEY` | - | Brave Search API 키 (wrangler secret) |
|
| `BRAVE_API_KEY` | - | Brave Search API 키 (wrangler secret) |
|
||||||
|
| `DEPOSIT_API_SECRET` | - | Deposit API 인증 키 (namecheap-api용, wrangler secret) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -316,7 +373,6 @@ curl -X POST 'https://api.openai.com/v1/assistants/asst_XMoVGU7ZwRpUPI6PHGvRNm8E
|
|||||||
| 서비스 | 용도 | 엔드포인트 | 주의사항 |
|
| 서비스 | 용도 | 엔드포인트 | 주의사항 |
|
||||||
|--------|------|-----------|----------|
|
|--------|------|-----------|----------|
|
||||||
| Context7 | 문서 조회 | context7.com API | - |
|
| Context7 | 문서 조회 | context7.com API | - |
|
||||||
| Domain Agent | 도메인 관리 | OpenAI Assistants | `asst_MzPFKoqt7V4w6bc0UwcXU4ob` |
|
|
||||||
| Deposit Agent | 예치금 관리 | OpenAI Assistants | `asst_XMoVGU7ZwRpUPI6PHGvRNm8E` |
|
| Deposit Agent | 예치금 관리 | OpenAI Assistants | `asst_XMoVGU7ZwRpUPI6PHGvRNm8E` |
|
||||||
| Namecheap API | 도메인 백엔드 | namecheap-api.anvil.it.com | 날짜: MM/DD/YYYY → ISO 변환 |
|
| Namecheap API | 도메인 백엔드 | namecheap-api.anvil.it.com | 날짜: MM/DD/YYYY → ISO 변환 |
|
||||||
| WHOIS API | WHOIS 조회 | whois-api-eight.vercel.app | ccSLD 미지원 |
|
| WHOIS API | WHOIS 조회 | whois-api-eight.vercel.app | ccSLD 미지원 |
|
||||||
@@ -411,17 +467,102 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## Domain System
|
## Domain System
|
||||||
|
|
||||||
**도구 목록:**
|
**아키텍처 변경 (2025-01):** Agent 기반 → 코드 직접 처리
|
||||||
| 도구 | 권한 | 설명 |
|
|
||||||
|------|------|------|
|
|
||||||
| `list_domains` | 소유자 | 도메인 목록 |
|
|
||||||
| `get_domain_info` | 소유자 | 상세 정보 (만료일 등) |
|
|
||||||
| `get_nameservers` | 공개 | 네임서버 조회 |
|
|
||||||
| `set_nameservers` | 소유자 | 네임서버 변경 |
|
|
||||||
| `get_price` | 공개 | TLD 가격 (원화) |
|
|
||||||
| `check_domains` | 공개 | 가용성 확인 |
|
|
||||||
| `whois_lookup` | 공개 | WHOIS 조회 |
|
|
||||||
|
|
||||||
**가격 정책:** Namecheap 원가 + 13%, 매일 환율 업데이트
|
| 구분 | 이전 (Agent) | 현재 (코드) |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| 의도 파악 | Domain Agent | 메인 AI (action 파라미터) |
|
||||||
|
| API 호출 | Agent Function Calling | `executeDomainAction()` |
|
||||||
|
| 응답 형식 | Agent 생성 (불안정) | 코드 고정 (100% 일관성) |
|
||||||
|
| 비용 | Agent 호출당 ~$0.01 | 없음 |
|
||||||
|
|
||||||
**권한 체크:** `user_domains` 테이블 `verified=1`
|
**manage_domain 도구 파라미터:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
action: 'register' | 'check' | 'whois' | 'list' | 'info' | 'get_ns' | 'set_ns' | 'price',
|
||||||
|
domain?: string, // 대상 도메인
|
||||||
|
nameservers?: string[], // set_ns용
|
||||||
|
tld?: string // price용
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**action별 처리:**
|
||||||
|
| action | 설명 | 권한 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `list` | 내 도메인 목록 | 소유자 |
|
||||||
|
| `info` | 도메인 상세정보 | 소유자 |
|
||||||
|
| `get_ns` | 네임서버 조회 | 공개 |
|
||||||
|
| `set_ns` | 네임서버 변경 | 소유자 |
|
||||||
|
| `check` | 가용성 확인 + 가격 | 공개 |
|
||||||
|
| `whois` | WHOIS 조회 | 공개 |
|
||||||
|
| `price` | TLD 가격 | 공개 |
|
||||||
|
| `register` | 등록 확인 페이지 | 사용자 |
|
||||||
|
|
||||||
|
### 도메인 등록 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "example.com 등록해줘"
|
||||||
|
↓
|
||||||
|
메인 AI: manage_domain(action="register", domain="example.com")
|
||||||
|
↓
|
||||||
|
executeDomainAction():
|
||||||
|
1. check_domains API → 가용성 확인
|
||||||
|
2. get_price API → 가격 조회
|
||||||
|
3. DB 조회 → 현재 잔액 확인
|
||||||
|
4. 고정 형식 응답 생성
|
||||||
|
↓
|
||||||
|
┌─────────────────────┬─────────────────────┐
|
||||||
|
│ 잔액 충분 시 │ 잔액 부족 시 │
|
||||||
|
├─────────────────────┼─────────────────────┤
|
||||||
|
│ 📋 도메인 등록 확인 │ 📋 도메인 등록 확인 │
|
||||||
|
│ • 도메인: example.com│ • 도메인: example.com│
|
||||||
|
│ • 가격: 15,000원 │ • 가격: 15,000원 │
|
||||||
|
│ • 현재 잔액: ✓ │ • 현재 잔액: ⚠️ 부족 │
|
||||||
|
│ • 등록 기간: 1년 │ • 부족 금액: X원 │
|
||||||
|
│ 📌 등록자 정보 │ │
|
||||||
|
│ ⚠️ 취소/환불 불가 │ 💳 입금 계좌 │
|
||||||
|
│ │ 하나은행 427-... │
|
||||||
|
│ '확인' 입력 요청 │ 입금 안내 │
|
||||||
|
└─────────────────────┴─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 도메인 추천 기능 (`suggest_domains`)
|
||||||
|
|
||||||
|
**별도 구현된 코드 레벨 도구**
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "커피숍 도메인 추천해줘"
|
||||||
|
↓
|
||||||
|
1. GPT-4o-mini: 키워드 기반 창의적 도메인 15개 생성
|
||||||
|
↓
|
||||||
|
2. Namecheap API: check_domains로 가용성 일괄 확인
|
||||||
|
↓
|
||||||
|
3. 등록 가능 도메인 < 10개? → 1-2 반복 (최대 3회)
|
||||||
|
↓
|
||||||
|
4. Namecheap API: TLD별 가격 조회
|
||||||
|
↓
|
||||||
|
5. 결과 포맷팅 (등록 가능한 것만 표시, 10개 목표)
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징:**
|
||||||
|
- 등록 가능 도메인만 표시 (이미 등록된 도메인 미표시)
|
||||||
|
- 10개 미만 시 자동 재시도 (최대 3회)
|
||||||
|
- 이전에 체크한 도메인은 제외하고 새로 생성
|
||||||
|
|
||||||
|
**Namecheap API:**
|
||||||
|
- 엔드포인트: `namecheap-api.anvil.it.com`
|
||||||
|
- 가격 정책: Namecheap 원가 + 13%, 매일 환율 업데이트
|
||||||
|
- 권한 체크: `user_domains` 테이블 `verified=1`
|
||||||
|
|
||||||
|
**등록자 정보:**
|
||||||
|
- 현재: 서비스 기본 정보만 지원 (일본 주소)
|
||||||
|
- WHOIS Guard 자동 적용 (개인정보 비공개)
|
||||||
|
- 추후: 사용자 본인 정보로 등록 옵션 추가 예정
|
||||||
|
|
||||||
|
**Deposit API (namecheap-api용):**
|
||||||
|
```
|
||||||
|
GET /api/deposit/balance?telegram_id=xxx # 잔액 조회
|
||||||
|
POST /api/deposit/deduct # 잔액 차감
|
||||||
|
{ telegram_id, amount, reason }
|
||||||
|
Header: X-API-Key: DEPOSIT_API_SECRET
|
||||||
|
```
|
||||||
|
|||||||
152
README.md
152
README.md
@@ -22,9 +22,10 @@
|
|||||||
|
|
||||||
- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원
|
- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원
|
||||||
- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축
|
- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축
|
||||||
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리**, **예치금 관리** 등 AI가 자동으로 도구 호출
|
- **Function Calling (8개)**: 날씨, 검색, 시간, 계산, 문서 조회, 도메인 관리, **도메인 추천**, 예치금 관리
|
||||||
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
||||||
- **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동
|
- **동적 도구 로딩**: 메시지 키워드 기반으로 필요한 도구만 선택하여 토큰 절약
|
||||||
|
- **도메인 추천**: GPT가 창의적 도메인 생성 → 가용성 자동 확인 → 가격과 함께 제안
|
||||||
- **Deposit Agent**: OpenAI Assistants API 기반 예치금 관리 에이전트 연동
|
- **Deposit Agent**: OpenAI Assistants API 기반 예치금 관리 에이전트 연동
|
||||||
- **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전
|
- **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전
|
||||||
- **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리
|
- **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리
|
||||||
@@ -40,9 +41,10 @@
|
|||||||
| **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 |
|
||||||
|
| **도메인 추천** | GPT + Namecheap API (코드 레벨) |
|
||||||
| **Deposit Agent** | 예치금 관리 (OpenAI Assistants) |
|
| **Deposit Agent** | 예치금 관리 (OpenAI Assistants) |
|
||||||
| **Namecheap API** | 도메인 조회/관리 백엔드 |
|
| **Namecheap API** | 도메인 조회/가용성/가격 백엔드 |
|
||||||
| **Email Workers** | SMS → 메일 파싱 (입금 알림) |
|
| **Email Workers** | SMS → 메일 파싱 (입금 알림) |
|
||||||
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
|
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
|
||||||
|
|
||||||
@@ -67,16 +69,15 @@
|
|||||||
│ (Function Call) │ 도구 호출 자동 판단
|
│ (Function Call) │ 도구 호출 자동 판단
|
||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
│
|
│
|
||||||
┌───┴───┬───────┬───────┬───────┬───────┬───────┐
|
┌───┴───┬───────┬───────┬───────┬───────┬───────┬───────┐
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금]
|
[날씨] [검색] [시간] [계산] [문서] [도메인] [도메인 [예치금]
|
||||||
│ │ │ │ │ │ │
|
│ │ │ │ │ │ 추천] │
|
||||||
│ │ │ │ │ │ └── Deposit Agent (Assistants API)
|
│ │ │ │ │ │ │ └── Deposit Agent
|
||||||
│ │ │ │ │ │ ↓
|
│ │ │ │ │ │ └── GPT + Namecheap API
|
||||||
│ │ │ │ │ └── Domain Agent D1 (자동 매칭)
|
│ │ │ │ │ └── 코드 직접 처리 → Namecheap API
|
||||||
│ │ │ │ │ ↓
|
│ │ │ │ └── Context7
|
||||||
│ │ │ │ └── Context7 Namecheap API
|
└───┬───┴───────┴───────┴───────┴───────────────────────────┘
|
||||||
└───┬───┴───────┴───────┴───────┴───────────────────┘
|
|
||||||
▼
|
▼
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
│ 최종 응답 생성 │
|
│ 최종 응답 생성 │
|
||||||
@@ -97,18 +98,29 @@
|
|||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
│ 20개 도달
|
│ 20개 도달
|
||||||
▼
|
▼
|
||||||
┌──────────────────┐
|
┌──────────────────────────────────────────┐
|
||||||
│ 프로필 분석 │ ← 사용자 발언만 추출하여 분석
|
│ 통합 프로필 분석 │
|
||||||
│ (OpenAI) │ 봇 응답은 무시
|
│ ┌─────────────────┐ ┌───────────────┐ │
|
||||||
└──────────────────┘
|
│ │ 기존 요약 3개 │ │ 새 메시지 20개 │ │
|
||||||
|
│ │ [v1][v2][v3] │ │ (사용자 발언) │ │
|
||||||
|
│ └────────┬────────┘ └───────┬───────┘ │
|
||||||
|
│ └──────────┬────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ OpenAI 통합 분석 │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
│ summaries │ ← 최근 3개만 유지 (슬라이딩 윈도우)
|
│ summaries │ ← 최근 3개만 유지 (슬라이딩 윈도우)
|
||||||
│ [v1] [v2] [v3] │
|
│ [v1] [v2] [v3] │ 새 v4 저장, v1 삭제
|
||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**3개 요약 통합 방식:**
|
||||||
|
- 프로필 업데이트 시 기존 요약 3개 + 새 메시지를 함께 AI에 전달
|
||||||
|
- AI가 모든 버전을 종합 분석하여 새로운 통합 프로필 생성
|
||||||
|
- 응답 생성 시에도 3개 요약 모두 컨텍스트로 제공 (최신 버전 우선)
|
||||||
|
|
||||||
### 프로필 분석 내용
|
### 프로필 분석 내용
|
||||||
|
|
||||||
| 추출 정보 | 설명 |
|
| 추출 정보 | 설명 |
|
||||||
@@ -134,7 +146,8 @@ 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 네임서버", ".com 가격", "google.com whois" | Domain Agent + WHOIS API |
|
| **도메인** | "도메인 목록", "anvil.it.com 네임서버", ".com 가격", "google.com whois" | 코드 직접 처리 + WHOIS API |
|
||||||
|
| **도메인 추천** | "커피숍 도메인 추천해줘", "스타트업 도메인 아이디어" | GPT + Namecheap |
|
||||||
| **예치금** | "잔액 확인", "충전하고 싶어", "10000원 입금했어" | D1 + Email Worker |
|
| **예치금** | "잔액 확인", "충전하고 싶어", "10000원 입금했어" | D1 + Email Worker |
|
||||||
|
|
||||||
### 동작 방식
|
### 동작 방식
|
||||||
@@ -329,7 +342,7 @@ function checkBankEmails() {
|
|||||||
|
|
||||||
## 도메인 관리
|
## 도메인 관리
|
||||||
|
|
||||||
OpenAI Assistants API 기반 도메인 관리 에이전트입니다.
|
코드 직접 처리 방식의 도메인 관리 기능입니다. 메인 AI가 action 파라미터로 작업을 지정하고, 코드에서 Namecheap API를 호출합니다.
|
||||||
|
|
||||||
### 지원 기능
|
### 지원 기능
|
||||||
|
|
||||||
@@ -342,6 +355,7 @@ OpenAI Assistants API 기반 도메인 관리 에이전트입니다.
|
|||||||
| `가격 조회` | TLD/ccSLD 등록 가격 (원화) | 누구나 |
|
| `가격 조회` | TLD/ccSLD 등록 가격 (원화) | 누구나 |
|
||||||
| `WHOIS 조회` | 공개 WHOIS 정보 (RDAP) | 누구나 |
|
| `WHOIS 조회` | 공개 WHOIS 정보 (RDAP) | 누구나 |
|
||||||
| `가용성 확인` | 도메인 등록 가능 여부 | 누구나 |
|
| `가용성 확인` | 도메인 등록 가능 여부 | 누구나 |
|
||||||
|
| `도메인 등록` | 새 도메인 등록 (예치금 차감) | 사용자 |
|
||||||
|
|
||||||
### 가격 조회
|
### 가격 조회
|
||||||
|
|
||||||
@@ -370,6 +384,96 @@ Namecheap 가격 + 13% 마진, 매일 환율 업데이트
|
|||||||
- **지원 TLD**: com, net, org, io, co, me, kr, jp, cn, uk, de, fr 등 40+ TLD
|
- **지원 TLD**: com, net, org, io, co, me, kr, jp, cn, uk, de, fr 등 40+ TLD
|
||||||
- **ccSLD 미지원**: it.com, uk.com, us.com 등 사설 레지스트리는 WHOIS 비공개
|
- **ccSLD 미지원**: it.com, uk.com, us.com 등 사설 레지스트리는 WHOIS 비공개
|
||||||
|
|
||||||
|
### 도메인 등록
|
||||||
|
|
||||||
|
예치금에서 자동 차감되는 도메인 등록 기능입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[잔액 충분한 경우]
|
||||||
|
사용자: "example123.com 등록해줘"
|
||||||
|
|
||||||
|
봇: 📋 도메인 등록 확인
|
||||||
|
|
||||||
|
• 도메인: example123.com
|
||||||
|
• 가격: 15,000원 (예치금에서 차감)
|
||||||
|
• 현재 잔액: 50,000원 ✓
|
||||||
|
• 등록 기간: 1년
|
||||||
|
|
||||||
|
📌 등록자 정보
|
||||||
|
서비스 기본 정보로 등록됩니다.
|
||||||
|
(WHOIS Guard가 적용되어 개인정보는 비공개)
|
||||||
|
|
||||||
|
⚠️ 주의사항
|
||||||
|
도메인 등록 후에는 취소 및 환불이 불가능합니다.
|
||||||
|
|
||||||
|
등록을 진행하시려면 '확인'이라고 입력해주세요.
|
||||||
|
|
||||||
|
사용자: "확인"
|
||||||
|
|
||||||
|
봇: ✅ example123.com 등록이 완료되었습니다!
|
||||||
|
예치금 15,000원 차감, 현재 잔액: 35,000원
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[잔액 부족한 경우]
|
||||||
|
사용자: "premium-domain.io 등록해줘"
|
||||||
|
|
||||||
|
봇: 📋 도메인 등록 확인
|
||||||
|
|
||||||
|
• 도메인: premium-domain.io
|
||||||
|
• 가격: 45,000원
|
||||||
|
• 현재 잔액: 10,000원 ⚠️ 부족
|
||||||
|
• 부족 금액: 35,000원
|
||||||
|
|
||||||
|
💳 입금 계좌
|
||||||
|
하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||||
|
입금 후 '홍길동 35000원 입금' 형식으로 알려주세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징:**
|
||||||
|
- 🔍 가용성 자동 확인 후 가격 안내
|
||||||
|
- 💰 **현재 잔액 실시간 표시**
|
||||||
|
- ⚠️ 등록 전 취소/환불 불가 안내
|
||||||
|
- 💳 **잔액 부족 시 입금 계좌 자동 안내**
|
||||||
|
- 📝 등록 즉시 소유 도메인으로 자동 등록
|
||||||
|
- 🔒 WHOIS Guard 자동 적용 (개인정보 보호)
|
||||||
|
|
||||||
|
**등록자 정보:**
|
||||||
|
- 현재: 서비스 기본 정보로 등록 (WHOIS Guard 적용)
|
||||||
|
- 추후: 사용자 본인 정보로 등록 옵션 추가 예정
|
||||||
|
|
||||||
|
### 도메인 추천
|
||||||
|
|
||||||
|
AI가 키워드를 기반으로 창의적인 도메인 이름을 생성하고, 가용성을 자동 확인하여 등록 가능한 도메인만 제안합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "커피숍 도메인 추천해줘"
|
||||||
|
|
||||||
|
봇: 🎯 커피숍 관련 도메인:
|
||||||
|
|
||||||
|
✅ 등록 가능:
|
||||||
|
1. coffeenest.com - 15,000원/년
|
||||||
|
2. brewlab.io - 45,000원/년
|
||||||
|
3. beanspot.co - 32,000원/년
|
||||||
|
4. roasthub.net - 18,000원/년
|
||||||
|
|
||||||
|
❌ 이미 등록됨:
|
||||||
|
- coffee.com
|
||||||
|
- brew.io
|
||||||
|
- bean.com
|
||||||
|
|
||||||
|
💎 프리미엄 도메인:
|
||||||
|
- coffeehouse.com (별도 문의)
|
||||||
|
|
||||||
|
등록하시려면 번호나 도메인명을 말씀해주세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징:**
|
||||||
|
- 🎨 **창의적 이름**: 트렌디한 접미사 (hub, lab, spot, nest, base, cloud, stack, flow)
|
||||||
|
- 🔍 **자동 가용성 확인**: 15개 아이디어 생성 후 일괄 확인
|
||||||
|
- 💰 **가격 표시**: 등록 가능한 도메인만 원화 가격 안내
|
||||||
|
- 💎 **프리미엄 분류**: 일반/프리미엄 도메인 구분 표시
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
@@ -441,6 +545,9 @@ wrangler secret put BANK_API_SECRET
|
|||||||
|
|
||||||
# Brave Search API Key
|
# Brave Search API Key
|
||||||
wrangler secret put BRAVE_API_KEY
|
wrangler secret put BRAVE_API_KEY
|
||||||
|
|
||||||
|
# Deposit API Secret (namecheap-api 연동용)
|
||||||
|
wrangler secret put DEPOSIT_API_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vault 연동 (선택)
|
### Vault 연동 (선택)
|
||||||
@@ -538,7 +645,6 @@ 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"
|
DOMAIN_OWNER_ID = "821596605"
|
||||||
DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E"
|
DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E"
|
||||||
DEPOSIT_ADMIN_ID = "821596605"
|
DEPOSIT_ADMIN_ID = "821596605"
|
||||||
@@ -575,6 +681,8 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
|||||||
| `/setup-webhook` | GET | Webhook 설정 |
|
| `/setup-webhook` | GET | Webhook 설정 |
|
||||||
| `/webhook` | POST | Telegram Webhook |
|
| `/webhook` | POST | Telegram Webhook |
|
||||||
| `/api/bank-notification` | POST | 입금 알림 API (Apps Script 연동) |
|
| `/api/bank-notification` | POST | 입금 알림 API (Apps Script 연동) |
|
||||||
|
| `/api/deposit/balance` | GET | 예치금 잔액 조회 (namecheap-api용) |
|
||||||
|
| `/api/deposit/deduct` | POST | 예치금 차감 (namecheap-api용) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
115
src/index.ts
115
src/index.ts
@@ -275,6 +275,121 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deposit API - 잔액 조회 (namecheap-api 전용)
|
||||||
|
if (url.pathname === '/api/deposit/balance' && request.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
||||||
|
const authHeader = request.headers.get('X-API-Key');
|
||||||
|
|
||||||
|
if (!apiSecret || authHeader !== apiSecret) {
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramId = url.searchParams.get('telegram_id');
|
||||||
|
if (!telegramId) {
|
||||||
|
return Response.json({ error: 'telegram_id required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 조회
|
||||||
|
const user = await env.DB.prepare(
|
||||||
|
'SELECT id FROM users WHERE telegram_id = ?'
|
||||||
|
).bind(telegramId).first<{ id: number }>();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잔액 조회
|
||||||
|
const deposit = await env.DB.prepare(
|
||||||
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(user.id).first<{ balance: number }>();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
telegram_id: telegramId,
|
||||||
|
balance: deposit?.balance || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Deposit balance error:', error);
|
||||||
|
return Response.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit API - 잔액 차감 (namecheap-api 전용)
|
||||||
|
if (url.pathname === '/api/deposit/deduct' && request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
||||||
|
const authHeader = request.headers.get('X-API-Key');
|
||||||
|
|
||||||
|
if (!apiSecret || authHeader !== apiSecret) {
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as {
|
||||||
|
telegram_id: string;
|
||||||
|
amount: number;
|
||||||
|
reason: string;
|
||||||
|
reference_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.telegram_id || !body.amount || !body.reason) {
|
||||||
|
return Response.json({ error: 'telegram_id, amount, reason required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.amount <= 0) {
|
||||||
|
return Response.json({ error: 'Amount must be positive' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 조회
|
||||||
|
const user = await env.DB.prepare(
|
||||||
|
'SELECT id FROM users WHERE telegram_id = ?'
|
||||||
|
).bind(body.telegram_id).first<{ id: number }>();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 잔액 확인
|
||||||
|
const deposit = await env.DB.prepare(
|
||||||
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(user.id).first<{ balance: number }>();
|
||||||
|
|
||||||
|
const currentBalance = deposit?.balance || 0;
|
||||||
|
if (currentBalance < body.amount) {
|
||||||
|
return Response.json({
|
||||||
|
error: 'Insufficient balance',
|
||||||
|
current_balance: currentBalance,
|
||||||
|
required: body.amount,
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랜잭션: 잔액 차감 + 거래 기록
|
||||||
|
await env.DB.batch([
|
||||||
|
env.DB.prepare(
|
||||||
|
'UPDATE user_deposits SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
||||||
|
).bind(body.amount, user.id),
|
||||||
|
env.DB.prepare(
|
||||||
|
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
|
||||||
|
VALUES (?, 'deduct', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
|
||||||
|
).bind(user.id, body.amount, body.reason),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const newBalance = currentBalance - body.amount;
|
||||||
|
|
||||||
|
console.log(`[API] Deposit deducted: user=${body.telegram_id}, amount=${body.amount}, reason=${body.reason}`);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
telegram_id: body.telegram_id,
|
||||||
|
deducted: body.amount,
|
||||||
|
previous_balance: currentBalance,
|
||||||
|
new_balance: newBalance,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Deposit deduct error:', error);
|
||||||
|
return Response.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Telegram Webhook 처리
|
// Telegram Webhook 처리
|
||||||
if (url.pathname === '/webhook') {
|
if (url.pathname === '/webhook') {
|
||||||
// 보안 검증
|
// 보안 검증
|
||||||
|
|||||||
@@ -119,16 +119,30 @@ const tools = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'manage_domain',
|
name: 'manage_domain',
|
||||||
description: '도메인을 관리합니다. 도메인 목록 조회, 도메인 정보 확인, 네임서버 조회/변경, 도메인 가용성 확인, 계정 잔액 조회, TLD 가격 조회 등을 수행할 수 있습니다.',
|
description: '도메인 관리 및 정보 조회. ".com 가격", ".io 가격" 같은 TLD 가격 조회, 도메인 등록, WHOIS 조회, 네임서버 관리 등을 처리합니다.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: {
|
action: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: '도메인 관리 요청 (예: 도메인 목록 보여줘, anvil.it.com 네임서버 확인, example.com 등록 가능한지 확인)',
|
enum: ['register', 'check', 'whois', 'list', 'info', 'get_ns', 'set_ns', 'price'],
|
||||||
|
description: 'price: TLD 가격 조회 (.com 가격, .io 가격), register: 도메인 등록, check: 가용성 확인, whois: WHOIS 조회, list: 내 도메인 목록, info: 도메인 상세정보, get_ns/set_ns: 네임서버 조회/변경',
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
type: 'string',
|
||||||
|
description: '대상 도메인 또는 TLD (예: example.com, .com, com). price action에서는 TLD만 전달 가능',
|
||||||
|
},
|
||||||
|
nameservers: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: '설정할 네임서버 목록. set_ns action에만 필요 (예: ["ns1.example.com", "ns2.example.com"])',
|
||||||
|
},
|
||||||
|
tld: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'TLD. price action에서 사용 (예: tld="com" 또는 domain=".com" 또는 domain="com" 모두 가능)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['action'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -149,10 +163,222 @@ const tools = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'suggest_domains',
|
||||||
|
description: '키워드나 비즈니스 설명을 기반으로 도메인 이름을 추천합니다. 창의적인 도메인 아이디어를 생성하고 가용성을 확인하여 등록 가능한 도메인만 가격과 함께 제안합니다. "도메인 추천", "도메인 제안", "도메인 아이디어" 등의 요청에 사용하세요.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
keywords: {
|
||||||
|
type: 'string',
|
||||||
|
description: '도메인 추천을 위한 키워드나 비즈니스 설명 (예: 커피숍, IT 스타트업, 서버 호스팅)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['keywords'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 동적 도구 로딩 시스템
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 도구 카테고리 정의
|
||||||
|
const TOOL_CATEGORIES: Record<string, string[]> = {
|
||||||
|
domain: ['manage_domain', 'suggest_domains'],
|
||||||
|
deposit: ['manage_deposit'],
|
||||||
|
weather: ['get_weather'],
|
||||||
|
search: ['search_web', 'lookup_docs'],
|
||||||
|
utility: ['get_current_time', 'calculate'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 감지 패턴 (느슨하게)
|
||||||
|
const CATEGORY_PATTERNS: Record<string, RegExp> = {
|
||||||
|
domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i,
|
||||||
|
deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i,
|
||||||
|
weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i,
|
||||||
|
search: /검색|찾아|뭐야|뭔가요|뉴스|최신/i,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메시지 기반 도구 선택
|
||||||
|
function selectToolsForMessage(message: string): typeof tools {
|
||||||
|
const selectedCategories = new Set<string>(['utility']); // 항상 포함
|
||||||
|
|
||||||
|
for (const [category, pattern] of Object.entries(CATEGORY_PATTERNS)) {
|
||||||
|
if (pattern.test(message)) {
|
||||||
|
selectedCategories.add(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패턴 매칭 없으면 전체 도구 사용 (폴백)
|
||||||
|
if (selectedCategories.size === 1) {
|
||||||
|
console.log('[ToolSelector] 패턴 매칭 없음 → 전체 도구 사용');
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNames = new Set(
|
||||||
|
[...selectedCategories].flatMap(cat => TOOL_CATEGORIES[cat] || [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTools = tools.filter(t => selectedNames.has(t.function.name));
|
||||||
|
|
||||||
|
console.log('[ToolSelector] 메시지:', message);
|
||||||
|
console.log('[ToolSelector] 카테고리:', [...selectedCategories].join(', '));
|
||||||
|
console.log('[ToolSelector] 선택된 도구:', selectedTools.map(t => t.function.name).join(', '));
|
||||||
|
|
||||||
|
return selectedTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도메인 추천 함수
|
||||||
|
async function suggestDomains(keywords: string, apiKey: string): Promise<string> {
|
||||||
|
const namecheapApiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
||||||
|
const namecheapApiUrl = 'https://namecheap-api.anvil.it.com';
|
||||||
|
const TARGET_COUNT = 10;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const availableDomains: { domain: string; price?: number }[] = [];
|
||||||
|
const checkedDomains = new Set<string>();
|
||||||
|
let retryCount = 0;
|
||||||
|
|
||||||
|
// 10개 이상 등록 가능 도메인을 찾을 때까지 반복
|
||||||
|
while (availableDomains.length < TARGET_COUNT && retryCount < MAX_RETRIES) {
|
||||||
|
retryCount++;
|
||||||
|
const excludeList = [...checkedDomains].slice(-30).join(', ');
|
||||||
|
|
||||||
|
// Step 1: GPT에게 도메인 아이디어 생성 요청
|
||||||
|
const ideaResponse = 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: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `당신은 도메인 이름 전문가입니다. 주어진 키워드/비즈니스 설명을 바탕으로 창의적이고 기억하기 쉬운 도메인 이름을 제안합니다.
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 정확히 15개의 도메인 이름을 제안하세요
|
||||||
|
- 다양한 TLD 사용: .com, .io, .net, .co, .app, .dev, .site, .xyz, .me
|
||||||
|
- 짧고 기억하기 쉬운 이름 (2-3 단어 조합)
|
||||||
|
- 트렌디한 접미사 활용: hub, lab, spot, nest, base, cloud, stack, flow, zone, pro
|
||||||
|
- JSON 배열로만 응답하세요. 설명 없이 도메인 목록만.
|
||||||
|
${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||||
|
|
||||||
|
예시 응답:
|
||||||
|
["coffeenest.com", "brewlab.io", "beanspot.co"]`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `키워드: ${keywords}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: 500,
|
||||||
|
temperature: 0.9,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ideaResponse.ok) {
|
||||||
|
if (availableDomains.length > 0) break; // 이미 찾은 게 있으면 그것으로 진행
|
||||||
|
return '🚫 도메인 아이디어 생성 중 오류가 발생했습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ideaData = await ideaResponse.json() as any;
|
||||||
|
const ideaContent = ideaData.choices?.[0]?.message?.content || '[]';
|
||||||
|
|
||||||
|
let domains: string[];
|
||||||
|
try {
|
||||||
|
domains = JSON.parse(ideaContent);
|
||||||
|
if (!Array.isArray(domains)) domains = [];
|
||||||
|
} catch {
|
||||||
|
const domainRegex = /[\w-]+\.(com|io|net|co|app|dev|site|org|xyz|me)/gi;
|
||||||
|
domains = ideaContent.match(domainRegex) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 체크한 도메인 제외
|
||||||
|
const newDomains = domains.filter(d => !checkedDomains.has(d.toLowerCase()));
|
||||||
|
if (newDomains.length === 0) continue;
|
||||||
|
|
||||||
|
newDomains.forEach(d => checkedDomains.add(d.toLowerCase()));
|
||||||
|
|
||||||
|
// Step 2: 가용성 확인
|
||||||
|
const checkResponse = await fetch(`${namecheapApiUrl}/domains/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': namecheapApiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ domains: newDomains }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkResponse.ok) continue;
|
||||||
|
|
||||||
|
const checkRaw = await checkResponse.json() as Record<string, boolean>;
|
||||||
|
|
||||||
|
// 등록 가능한 도메인만 추가
|
||||||
|
for (const [domain, isAvailable] of Object.entries(checkRaw)) {
|
||||||
|
if (isAvailable && availableDomains.length < TARGET_COUNT) {
|
||||||
|
availableDomains.push({ domain });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableDomains.length === 0) {
|
||||||
|
return `🎯 **${keywords}** 관련 도메인:\n\n❌ 등록 가능한 도메인을 찾지 못했습니다.\n다른 키워드로 다시 시도해주세요.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 가격 조회
|
||||||
|
const tldPrices: Record<string, number> = {};
|
||||||
|
const uniqueTlds = [...new Set(availableDomains.map(d => d.domain.split('.').pop() || ''))];
|
||||||
|
|
||||||
|
for (const tld of uniqueTlds) {
|
||||||
|
try {
|
||||||
|
const priceRes = await fetch(`${namecheapApiUrl}/prices/${tld}`, {
|
||||||
|
headers: { 'X-API-Key': namecheapApiKey },
|
||||||
|
});
|
||||||
|
if (priceRes.ok) {
|
||||||
|
const priceData = await priceRes.json() as { krw?: number };
|
||||||
|
tldPrices[tld] = priceData.krw || 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 가격 조회 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: 결과 포맷팅 (등록 가능한 것만)
|
||||||
|
let response = `🎯 **${keywords}** 관련 도메인:\n\n`;
|
||||||
|
|
||||||
|
availableDomains.forEach((d, i) => {
|
||||||
|
const tld = d.domain.split('.').pop() || '';
|
||||||
|
const price = tldPrices[tld];
|
||||||
|
const priceStr = price ? `${price.toLocaleString()}원/년` : '가격 조회 중';
|
||||||
|
response += `${i + 1}. ${d.domain} - ${priceStr}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
response += `\n등록하시려면 번호나 도메인명을 말씀해주세요.`;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[suggestDomains] 오류:', error);
|
||||||
|
return `🚫 도메인 추천 중 오류가 발생했습니다: ${String(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Namecheap API 호출 (allowedDomains로 필터링)
|
// Namecheap API 호출 (allowedDomains로 필터링)
|
||||||
async function callNamecheapApi(funcName: string, funcArgs: Record<string, any>, allowedDomains: string[]): Promise<any> {
|
async function callNamecheapApi(
|
||||||
|
funcName: string,
|
||||||
|
funcArgs: Record<string, any>,
|
||||||
|
allowedDomains: string[],
|
||||||
|
telegramUserId?: string,
|
||||||
|
db?: D1Database,
|
||||||
|
userId?: number
|
||||||
|
): Promise<any> {
|
||||||
const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
||||||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
||||||
|
|
||||||
@@ -322,130 +548,256 @@ async function callNamecheapApi(funcName: string, funcArgs: Record<string, any>,
|
|||||||
return { error: `WHOIS 조회 오류: ${String(error)}` };
|
return { error: `WHOIS 조회 오류: ${String(error)}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case 'register_domain': {
|
||||||
|
if (!telegramUserId) {
|
||||||
|
return { error: '도메인 등록에는 로그인이 필요합니다.' };
|
||||||
|
}
|
||||||
|
const res = await fetch(`${apiUrl}/domains/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: funcArgs.domain,
|
||||||
|
years: funcArgs.years || 1,
|
||||||
|
telegram_id: telegramUserId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await res.json() as any;
|
||||||
|
if (!res.ok) {
|
||||||
|
return { error: result.detail || '도메인 등록 실패' };
|
||||||
|
}
|
||||||
|
// 등록 성공 시 user_domains 테이블에 추가
|
||||||
|
if (result.registered && db && userId) {
|
||||||
|
try {
|
||||||
|
await db.prepare(
|
||||||
|
'INSERT INTO user_domains (user_id, domain, verified, created_at) VALUES (?, ?, 1, datetime("now"))'
|
||||||
|
).bind(userId, funcArgs.domain).run();
|
||||||
|
console.log(`[register_domain] user_domains에 추가: user_id=${userId}, domain=${funcArgs.domain}`);
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('[register_domain] user_domains 추가 실패:', dbError);
|
||||||
|
result.warning = result.warning || '';
|
||||||
|
result.warning += ' (DB 기록 실패 - 수동 추가 필요)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { error: `Unknown function: ${funcName}` };
|
return { error: `Unknown function: ${funcName}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 도메인 에이전트 (Assistants API - 기존 Agent 활용)
|
// 도메인 작업 직접 실행 (Agent 없이 코드로 처리)
|
||||||
async function callDomainAgent(
|
async function executeDomainAction(
|
||||||
apiKey: string,
|
action: string,
|
||||||
assistantId: string,
|
args: { domain?: string; nameservers?: string[]; tld?: string },
|
||||||
query: string,
|
allowedDomains: string[],
|
||||||
allowedDomains: string[] = []
|
telegramUserId?: string,
|
||||||
|
db?: D1Database,
|
||||||
|
userId?: number
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
const { domain, nameservers, tld } = args;
|
||||||
// 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. 메시지 추가 (허용 도메인 명시 + 응답 스타일 지시)
|
switch (action) {
|
||||||
const domainList = allowedDomains.join(', ');
|
case 'list': {
|
||||||
const instructions = `[시스템 지시]
|
const result = await callNamecheapApi('list_domains', {}, allowedDomains, telegramUserId, db, userId);
|
||||||
- 관리 가능 도메인: ${domainList}
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
- 한국어로 질문하면 한국어로 답변하고, 가격은 원화(KRW)만 표시
|
if (!result.length) return '📋 등록된 도메인이 없습니다.';
|
||||||
- 영어로 질문하면 영어로 답변하고, 가격은 달러(USD)로 표시
|
const list = result.map((d: any) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||||||
- 가격 응답 시 불필요한 달러 환산 정보 생략
|
return `📋 내 도메인 목록 (${result.length}개)\n\n${list}`;
|
||||||
|
|
||||||
[사용자 질문]
|
|
||||||
${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 제출
|
case 'info': {
|
||||||
const submitRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}/submit_tool_outputs`, {
|
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||||
method: 'POST',
|
const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||||
headers: {
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
'Content-Type': 'application/json',
|
return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`;
|
||||||
'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));
|
case 'get_ns': {
|
||||||
maxPolls--;
|
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||||
|
const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||||
const statusRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, {
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
headers: {
|
const nsList = (result.nameservers || result).map((ns: string) => `• ${ns}`).join('\n');
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
return `🌐 ${domain} 네임서버\n\n${nsList}`;
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
run = await statusRes.json() as { id: string; status: string; required_action?: any };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.status === 'failed') return '도메인 에이전트 실행 실패';
|
case 'set_ns': {
|
||||||
if (maxPolls === 0) return '응답 시간 초과. 다시 시도해주세요.';
|
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||||
|
if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.';
|
||||||
// 5. 메시지 조회
|
if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`;
|
||||||
const messagesRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, telegramUserId, db, userId);
|
||||||
headers: {
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`;
|
||||||
'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 '도메인 에이전트 응답 없음';
|
case 'check': {
|
||||||
} catch (error) {
|
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||||
return `도메인 에이전트 오류: ${String(error)}`;
|
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, telegramUserId, db, userId);
|
||||||
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
|
const available = result[domain];
|
||||||
|
if (available) {
|
||||||
|
// 가격도 함께 조회
|
||||||
|
const domainTld = domain.split('.').pop() || '';
|
||||||
|
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, telegramUserId, db, userId);
|
||||||
|
const price = priceResult.krw || priceResult.register_krw;
|
||||||
|
return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${price?.toLocaleString()}원/년\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`;
|
||||||
|
}
|
||||||
|
return `❌ ${domain}은 이미 등록된 도메인입니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'whois': {
|
||||||
|
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||||
|
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||||
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
|
|
||||||
|
// ccSLD WHOIS 미지원
|
||||||
|
if (result.whois_supported === false) {
|
||||||
|
return `🔍 ${domain} WHOIS\n\n⚠️ ${result.message}\n💡 ${result.suggestion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw WHOIS 데이터에서 주요 정보 추출
|
||||||
|
const raw = result.raw || '';
|
||||||
|
const extractField = (patterns: RegExp[]): string => {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = raw.match(pattern);
|
||||||
|
if (match) return match[1].trim();
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = extractField([
|
||||||
|
/Creation Date:\s*(.+)/i,
|
||||||
|
/Created Date:\s*(.+)/i,
|
||||||
|
/Registration Date:\s*(.+)/i,
|
||||||
|
/created:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const expires = extractField([
|
||||||
|
/Registry Expiry Date:\s*(.+)/i,
|
||||||
|
/Expiration Date:\s*(.+)/i,
|
||||||
|
/Expiry Date:\s*(.+)/i,
|
||||||
|
/expires:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const updated = extractField([
|
||||||
|
/Updated Date:\s*(.+)/i,
|
||||||
|
/Last Updated:\s*(.+)/i,
|
||||||
|
/modified:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const registrar = extractField([
|
||||||
|
/Registrar:\s*(.+)/i,
|
||||||
|
/Sponsoring Registrar:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const registrarUrl = extractField([
|
||||||
|
/Registrar URL:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const registrant = extractField([
|
||||||
|
/Registrant Organization:\s*(.+)/i,
|
||||||
|
/Registrant Name:\s*(.+)/i,
|
||||||
|
/org:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const registrantCountry = extractField([
|
||||||
|
/Registrant Country:\s*(.+)/i,
|
||||||
|
/Registrant State\/Province:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const statusMatch = raw.match(/Domain Status:\s*(.+)/gi);
|
||||||
|
const statuses = statusMatch
|
||||||
|
? statusMatch.map(s => s.replace(/Domain Status:\s*/i, '').split(' ')[0].trim()).slice(0, 3)
|
||||||
|
: [];
|
||||||
|
const dnssec = extractField([
|
||||||
|
/DNSSEC:\s*(.+)/i,
|
||||||
|
]);
|
||||||
|
const nsMatch = raw.match(/Name Server:\s*(.+)/gi);
|
||||||
|
const nameservers = nsMatch
|
||||||
|
? nsMatch.map(ns => ns.replace(/Name Server:\s*/i, '').trim()).slice(0, 4)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let response = `🔍 ${domain} WHOIS 정보\n\n`;
|
||||||
|
response += `📅 날짜\n`;
|
||||||
|
response += `• 등록일: ${created}\n`;
|
||||||
|
response += `• 만료일: ${expires}\n`;
|
||||||
|
if (updated !== '-') response += `• 수정일: ${updated}\n`;
|
||||||
|
response += `\n🏢 등록 정보\n`;
|
||||||
|
response += `• 등록기관: ${registrar}\n`;
|
||||||
|
if (registrarUrl !== '-') response += `• URL: ${registrarUrl}\n`;
|
||||||
|
if (registrant !== '-') response += `• 등록자: ${registrant}\n`;
|
||||||
|
if (registrantCountry !== '-') response += `• 국가: ${registrantCountry}\n`;
|
||||||
|
response += `\n🌐 기술 정보\n`;
|
||||||
|
response += `• 네임서버: ${nameservers.length ? nameservers.join(', ') : '-'}\n`;
|
||||||
|
if (statuses.length) response += `• 상태: ${statuses.join(', ')}\n`;
|
||||||
|
if (dnssec !== '-') response += `• DNSSEC: ${dnssec}`;
|
||||||
|
|
||||||
|
if (result.available === true) {
|
||||||
|
response += `\n\n✅ 이 도메인은 등록 가능합니다!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'price': {
|
||||||
|
// tld, domain, 또는 ".com" 형식 모두 지원
|
||||||
|
let targetTld = tld || domain?.replace(/^\./, '').split('.').pop();
|
||||||
|
if (!targetTld) return '🚫 TLD를 지정해주세요. (예: com, io, net)';
|
||||||
|
const result = await callNamecheapApi('get_price', { tld: targetTld }, allowedDomains, telegramUserId, db, userId);
|
||||||
|
if (result.error) return `🚫 ${result.error}`;
|
||||||
|
// API 응답: { tld, usd, krw }
|
||||||
|
const price = result.krw || result.register_krw;
|
||||||
|
return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${price?.toLocaleString()}원/년`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'register': {
|
||||||
|
if (!domain) return '🚫 등록할 도메인을 지정해주세요.';
|
||||||
|
if (!telegramUserId) return '🚫 도메인 등록에는 로그인이 필요합니다.';
|
||||||
|
|
||||||
|
// 1. 가용성 확인
|
||||||
|
const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, telegramUserId, db, userId);
|
||||||
|
if (checkResult.error) return `🚫 ${checkResult.error}`;
|
||||||
|
if (!checkResult[domain]) return `❌ ${domain}은 이미 등록된 도메인입니다.`;
|
||||||
|
|
||||||
|
// 2. 가격 조회
|
||||||
|
const domainTld = domain.split('.').pop() || '';
|
||||||
|
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, telegramUserId, db, userId);
|
||||||
|
if (priceResult.error) return `🚫 가격 조회 실패: ${priceResult.error}`;
|
||||||
|
const price = priceResult.krw || priceResult.register_krw;
|
||||||
|
|
||||||
|
// 3. 잔액 조회
|
||||||
|
let balance = 0;
|
||||||
|
if (db && userId) {
|
||||||
|
const balanceRow = await db.prepare('SELECT balance FROM user_deposits WHERE user_id = ?').bind(userId).first<{ balance: number }>();
|
||||||
|
balance = balanceRow?.balance || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 확인 페이지 생성 (코드에서 고정 형식)
|
||||||
|
if (balance >= price) {
|
||||||
|
return `📋 도메인 등록 확인
|
||||||
|
|
||||||
|
• 도메인: ${domain}
|
||||||
|
• 가격: ${price.toLocaleString()}원 (예치금에서 차감)
|
||||||
|
• 현재 잔액: ${balance.toLocaleString()}원 ✓
|
||||||
|
• 등록 기간: 1년
|
||||||
|
|
||||||
|
📌 등록자 정보
|
||||||
|
서비스 기본 정보로 등록됩니다.
|
||||||
|
(WHOIS Guard가 적용되어 개인정보는 비공개)
|
||||||
|
|
||||||
|
⚠️ 주의사항
|
||||||
|
도메인 등록 후에는 취소 및 환불이 불가능합니다.
|
||||||
|
|
||||||
|
등록을 진행하시려면 '확인'이라고 입력해주세요.`;
|
||||||
|
} else {
|
||||||
|
const shortage = price - balance;
|
||||||
|
return `📋 도메인 등록 확인
|
||||||
|
|
||||||
|
• 도메인: ${domain}
|
||||||
|
• 가격: ${price.toLocaleString()}원
|
||||||
|
• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족
|
||||||
|
• 부족 금액: ${shortage.toLocaleString()}원
|
||||||
|
|
||||||
|
💳 입금 계좌
|
||||||
|
하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||||
|
입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `🚫 알 수 없는 작업: ${action}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,13 +824,53 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
|
|
||||||
case 'search_web': {
|
case 'search_web': {
|
||||||
// Brave Search API
|
// Brave Search API
|
||||||
const query = args.query;
|
let query = args.query;
|
||||||
try {
|
try {
|
||||||
if (!env?.BRAVE_API_KEY) {
|
if (!env?.BRAVE_API_KEY) {
|
||||||
return `🔍 검색 기능이 설정되지 않았습니다.`;
|
return `🔍 검색 기능이 설정되지 않았습니다.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 한글이 포함된 경우 영문으로 번역 (기술 용어, 제품명 등)
|
||||||
|
const hasKorean = /[가-힣]/.test(query);
|
||||||
|
let translatedQuery = query;
|
||||||
|
|
||||||
|
if (hasKorean && env?.OPENAI_API_KEY) {
|
||||||
|
try {
|
||||||
|
const translateRes = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `사용자의 검색어를 영문으로 번역하세요.
|
||||||
|
- 외래어/기술용어는 원래 영문 표기로 변환 (예: 판골린→Pangolin, 도커→Docker)
|
||||||
|
- 일반 한국어는 영문으로 번역
|
||||||
|
- 검색에 최적화된 키워드로 변환
|
||||||
|
- 번역된 검색어만 출력, 설명 없이`
|
||||||
|
},
|
||||||
|
{ role: 'user', content: query }
|
||||||
|
],
|
||||||
|
max_tokens: 100,
|
||||||
|
temperature: 0.3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (translateRes.ok) {
|
||||||
|
const translateData = await translateRes.json() as any;
|
||||||
|
translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query;
|
||||||
|
console.log(`[search_web] 번역: "${query}" → "${translatedQuery}"`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 번역 실패 시 원본 사용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`,
|
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -501,7 +893,12 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}`
|
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}`
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
return `🔍 검색 결과: ${query}\n\n${results}`;
|
// 번역된 경우 원본 쿼리도 표시
|
||||||
|
const queryDisplay = (hasKorean && translatedQuery !== query)
|
||||||
|
? `${query} (→ ${translatedQuery})`
|
||||||
|
: query;
|
||||||
|
|
||||||
|
return `🔍 검색 결과: ${queryDisplay}\n\n${results}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
||||||
}
|
}
|
||||||
@@ -613,9 +1010,27 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'suggest_domains': {
|
||||||
|
const keywords = args.keywords;
|
||||||
|
console.log('[suggest_domains] 시작:', { keywords });
|
||||||
|
|
||||||
|
if (!env?.OPENAI_API_KEY) {
|
||||||
|
return '🚫 도메인 추천 기능이 설정되지 않았습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await suggestDomains(keywords, env.OPENAI_API_KEY);
|
||||||
|
console.log('[suggest_domains] 완료:', result?.slice(0, 100));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[suggest_domains] 오류:', error);
|
||||||
|
return `🚫 도메인 추천 오류: ${String(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'manage_domain': {
|
case 'manage_domain': {
|
||||||
const query = args.query;
|
const { action, domain, nameservers, tld } = args;
|
||||||
console.log('[manage_domain] 시작:', { query, telegramUserId, hasDb: !!db });
|
console.log('[manage_domain] 시작:', { action, domain, telegramUserId, hasDb: !!db });
|
||||||
|
|
||||||
// 소유권 검증 (DB 조회)
|
// 소유권 검증 (DB 조회)
|
||||||
if (!telegramUserId || !db) {
|
if (!telegramUserId || !db) {
|
||||||
@@ -624,15 +1039,16 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
}
|
}
|
||||||
|
|
||||||
let userDomains: string[] = [];
|
let userDomains: string[] = [];
|
||||||
|
let userId: number | undefined;
|
||||||
try {
|
try {
|
||||||
const user = await db.prepare(
|
const user = await db.prepare(
|
||||||
'SELECT id FROM users WHERE telegram_id = ?'
|
'SELECT id FROM users WHERE telegram_id = ?'
|
||||||
).bind(telegramUserId).first<{ id: number }>();
|
).bind(telegramUserId).first<{ id: number }>();
|
||||||
console.log('[manage_domain] user 조회 결과:', user);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return '🚫 도메인 관리 권한이 없습니다.';
|
return '🚫 도메인 관리 권한이 없습니다.';
|
||||||
}
|
}
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
// 사용자 소유 도메인 전체 목록 조회
|
// 사용자 소유 도메인 전체 목록 조회
|
||||||
const domains = await db.prepare(
|
const domains = await db.prepare(
|
||||||
@@ -640,32 +1056,26 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
).bind(user.id).all<{ domain: string }>();
|
).bind(user.id).all<{ domain: string }>();
|
||||||
userDomains = domains.results?.map(d => d.domain) || [];
|
userDomains = domains.results?.map(d => d.domain) || [];
|
||||||
console.log('[manage_domain] 소유 도메인:', userDomains);
|
console.log('[manage_domain] 소유 도메인:', userDomains);
|
||||||
|
|
||||||
if (userDomains.length === 0) {
|
|
||||||
return '🚫 등록된 도메인이 없습니다. 먼저 도메인을 등록해주세요.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[manage_domain] DB 오류:', error);
|
console.log('[manage_domain] DB 오류:', error);
|
||||||
return '🚫 권한 확인 중 오류가 발생했습니다.';
|
return '🚫 권한 확인 중 오류가 발생했습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!env?.OPENAI_API_KEY || !env?.DOMAIN_AGENT_ID) {
|
// 코드로 직접 처리 (Agent 없이)
|
||||||
console.log('[manage_domain] env 설정 없음');
|
|
||||||
return '🌐 도메인 관리 기능이 설정되지 않았습니다.';
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
console.log('[manage_domain] callDomainAgent 호출 시작');
|
const result = await executeDomainAction(
|
||||||
const result = await callDomainAgent(env.OPENAI_API_KEY, env.DOMAIN_AGENT_ID, query, userDomains);
|
action,
|
||||||
console.log('[manage_domain] callDomainAgent 완료:', result?.slice(0, 100));
|
{ domain, nameservers, tld },
|
||||||
// Markdown → HTML 변환
|
userDomains,
|
||||||
const htmlResult = result
|
telegramUserId,
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
db,
|
||||||
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
userId
|
||||||
.replace(/`(.+?)`/g, '<code>$1</code>');
|
);
|
||||||
return `🌐 ${htmlResult}`;
|
console.log('[manage_domain] 완료:', result?.slice(0, 100));
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[manage_domain] callDomainAgent 오류:', error);
|
console.log('[manage_domain] 오류:', error);
|
||||||
return `🌐 도메인 관리 오류: ${String(error)}`;
|
return `🚫 도메인 관리 오류: ${String(error)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,7 +1088,7 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
async function callOpenAI(
|
async function callOpenAI(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
messages: OpenAIMessage[],
|
messages: OpenAIMessage[],
|
||||||
useTools: boolean = true
|
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
||||||
): Promise<OpenAIResponse> {
|
): Promise<OpenAIResponse> {
|
||||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -689,8 +1099,8 @@ async function callOpenAI(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'gpt-4o-mini',
|
model: 'gpt-4o-mini',
|
||||||
messages,
|
messages,
|
||||||
tools: useTools ? tools : undefined,
|
tools: selectedTools?.length ? selectedTools : undefined,
|
||||||
tool_choice: useTools ? 'auto' : undefined,
|
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
||||||
max_tokens: 1000,
|
max_tokens: 1000,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -725,10 +1135,16 @@ export async function generateOpenAIResponse(
|
|||||||
{ role: 'user', content: userMessage },
|
{ role: 'user', content: userMessage },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 동적 도구 선택
|
||||||
|
const selectedTools = selectToolsForMessage(userMessage);
|
||||||
|
|
||||||
// 첫 번째 호출
|
// 첫 번째 호출
|
||||||
let response = await callOpenAI(env.OPENAI_API_KEY, messages);
|
let response = await callOpenAI(env.OPENAI_API_KEY, messages, selectedTools);
|
||||||
let assistantMessage = response.choices[0].message;
|
let assistantMessage = response.choices[0].message;
|
||||||
|
|
||||||
|
console.log('[OpenAI] tool_calls:', assistantMessage.tool_calls ? JSON.stringify(assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments }))) : 'none');
|
||||||
|
console.log('[OpenAI] content:', assistantMessage.content?.slice(0, 100));
|
||||||
|
|
||||||
// Function Calling 처리 (최대 3회 반복)
|
// Function Calling 처리 (최대 3회 반복)
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
while (assistantMessage.tool_calls && iterations < 3) {
|
while (assistantMessage.tool_calls && iterations < 3) {
|
||||||
@@ -754,12 +1170,14 @@ export async function generateOpenAIResponse(
|
|||||||
});
|
});
|
||||||
messages.push(...toolResults);
|
messages.push(...toolResults);
|
||||||
|
|
||||||
// 다시 호출
|
// 다시 호출 (도구 없이 응답 생성)
|
||||||
response = await callOpenAI(env.OPENAI_API_KEY, messages, false);
|
response = await callOpenAI(env.OPENAI_API_KEY, messages, undefined);
|
||||||
assistantMessage = response.choices[0].message;
|
assistantMessage = response.choices[0].message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assistantMessage.content || '응답을 생성할 수 없습니다.';
|
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||||
|
|
||||||
|
return finalResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프로필 생성용 (도구 없이)
|
// 프로필 생성용 (도구 없이)
|
||||||
@@ -774,7 +1192,7 @@ export async function generateProfileWithOpenAI(
|
|||||||
const response = await callOpenAI(
|
const response = await callOpenAI(
|
||||||
env.OPENAI_API_KEY,
|
env.OPENAI_API_KEY,
|
||||||
[{ role: 'user', content: prompt }],
|
[{ role: 'user', content: prompt }],
|
||||||
false
|
undefined // 도구 없이 호출
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.choices[0].message.content || '프로필 생성 실패';
|
return response.choices[0].message.content || '프로필 생성 실패';
|
||||||
|
|||||||
@@ -69,30 +69,52 @@ export async function getLatestSummary(
|
|||||||
return summary || null;
|
return summary || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모든 요약 조회 (최대 3개, 최신순)
|
||||||
|
export async function getAllSummaries(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
chatId: string
|
||||||
|
): Promise<Summary[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT id, generation, summary, message_count, created_at
|
||||||
|
FROM summaries
|
||||||
|
WHERE user_id = ? AND chat_id = ?
|
||||||
|
ORDER BY generation DESC
|
||||||
|
LIMIT 3
|
||||||
|
`)
|
||||||
|
.bind(userId, chatId)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return (results || []) as Summary[];
|
||||||
|
}
|
||||||
|
|
||||||
// 전체 컨텍스트 조회
|
// 전체 컨텍스트 조회
|
||||||
export async function getConversationContext(
|
export async function getConversationContext(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: number,
|
userId: number,
|
||||||
chatId: string
|
chatId: string
|
||||||
): Promise<ConversationContext> {
|
): Promise<ConversationContext> {
|
||||||
const [previousSummary, recentMessages] = await Promise.all([
|
const [summaries, recentMessages] = await Promise.all([
|
||||||
getLatestSummary(db, userId, chatId),
|
getAllSummaries(db, userId, chatId),
|
||||||
getBufferedMessages(db, userId, chatId),
|
getBufferedMessages(db, userId, chatId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const previousSummary = summaries[0] || null; // 최신 요약 (호환성)
|
||||||
const totalMessages = (previousSummary?.message_count || 0) + recentMessages.length;
|
const totalMessages = (previousSummary?.message_count || 0) + recentMessages.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previousSummary,
|
previousSummary,
|
||||||
|
summaries,
|
||||||
recentMessages,
|
recentMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 요약 생성
|
// AI 요약 생성 (모든 요약 통합)
|
||||||
async function generateSummary(
|
async function generateSummary(
|
||||||
env: Env,
|
env: Env,
|
||||||
previousSummary: string | null,
|
allSummaries: Summary[],
|
||||||
messages: BufferedMessage[]
|
messages: BufferedMessage[]
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// 사용자 메시지만 추출
|
// 사용자 메시지만 추출
|
||||||
@@ -106,29 +128,37 @@ async function generateSummary(
|
|||||||
|
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
|
|
||||||
if (previousSummary) {
|
if (allSummaries.length > 0) {
|
||||||
prompt = `당신은 사용자 프로필 분석 전문가입니다.
|
// 모든 기존 프로필 통합 (오래된 것부터)
|
||||||
기존 사용자 프로필과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요.
|
const existingProfiles = allSummaries
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((s) => `[v${s.generation}] ${s.summary}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
## 기존 사용자 프로필
|
prompt = `당신은 사용자 프로필 분석 전문가입니다.
|
||||||
${previousSummary}
|
기존 사용자 프로필들과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요.
|
||||||
|
|
||||||
|
## 기존 사용자 프로필 (${allSummaries.length}개 버전)
|
||||||
|
${existingProfiles}
|
||||||
|
|
||||||
## 새로운 사용자 발언 (${userMsgCount}개)
|
## 새로운 사용자 발언 (${userMsgCount}개)
|
||||||
${userMessages}
|
${userMessages}
|
||||||
|
|
||||||
## 요구사항
|
## 요구사항
|
||||||
1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석
|
1. **통합 분석**: 모든 기존 프로필을 종합하고 새로운 정보를 추가
|
||||||
2. **의미 있는 정보 추출**:
|
2. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석
|
||||||
|
3. **의미 있는 정보 추출**:
|
||||||
- 사용자의 관심사, 취미, 선호도
|
- 사용자의 관심사, 취미, 선호도
|
||||||
- 질문한 주제들 (무엇에 대해 알고 싶어하는지)
|
- 질문한 주제들 (무엇에 대해 알고 싶어하는지)
|
||||||
- 요청사항, 목표, 해결하려는 문제
|
- 요청사항, 목표, 해결하려는 문제
|
||||||
- 개인적 맥락 (직업, 상황, 배경 등)
|
- 개인적 맥락 (직업, 상황, 배경 등)
|
||||||
- 감정 상태나 태도 변화
|
- 관심사 변화 추이
|
||||||
3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략
|
4. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략
|
||||||
4. **간결하게**: 300-400자 이내
|
5. **간결하게**: 400-500자 이내
|
||||||
5. **한국어로 작성**
|
6. **한국어로 작성**
|
||||||
|
|
||||||
업데이트된 사용자 프로필:`;
|
통합된 사용자 프로필:`;
|
||||||
} else {
|
} else {
|
||||||
prompt = `당신은 사용자 프로필 분석 전문가입니다.
|
prompt = `당신은 사용자 프로필 분석 전문가입니다.
|
||||||
대화 내용에서 사용자에 대한 정보를 추출하여 프로필을 작성하세요.
|
대화 내용에서 사용자에 대한 정보를 추출하여 프로필을 작성하세요.
|
||||||
@@ -200,17 +230,19 @@ export async function processAndSummarize(
|
|||||||
return { summarized: false };
|
return { summarized: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSummary = await getLatestSummary(env.DB, userId, chatId);
|
// 모든 기존 요약 조회 (통합 분석용)
|
||||||
|
const allSummaries = await getAllSummaries(env.DB, userId, chatId);
|
||||||
|
const latestSummary = allSummaries[0] || null;
|
||||||
|
|
||||||
// AI 요약 생성
|
// AI 요약 생성 (모든 요약 통합)
|
||||||
const newSummary = await generateSummary(
|
const newSummary = await generateSummary(
|
||||||
env,
|
env,
|
||||||
previousSummary?.summary || null,
|
allSummaries,
|
||||||
messages
|
messages
|
||||||
);
|
);
|
||||||
|
|
||||||
const newGeneration = (previousSummary?.generation || 0) + 1;
|
const newGeneration = (latestSummary?.generation || 0) + 1;
|
||||||
const newMessageCount = (previousSummary?.message_count || 0) + messages.length;
|
const newMessageCount = (latestSummary?.message_count || 0) + messages.length;
|
||||||
|
|
||||||
// 트랜잭션 실행
|
// 트랜잭션 실행
|
||||||
await env.DB.batch([
|
await env.DB.batch([
|
||||||
@@ -242,18 +274,30 @@ export async function generateAIResponse(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const context = await getConversationContext(env.DB, userId, chatId);
|
const context = await getConversationContext(env.DB, userId, chatId);
|
||||||
|
|
||||||
const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다.
|
// 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시)
|
||||||
${context.previousSummary ? `
|
const integratedProfile = context.summaries.length > 0
|
||||||
## 사용자 프로필
|
? context.summaries
|
||||||
${context.previousSummary.summary}
|
.slice()
|
||||||
|
.reverse() // 오래된 것부터 표시
|
||||||
|
.map((s, i) => `[v${s.generation}] ${s.summary}`)
|
||||||
|
.join('\n\n')
|
||||||
|
: null;
|
||||||
|
|
||||||
위 프로필을 바탕으로 사용자의 관심사와 맥락을 이해하고 개인화된 응답을 제공하세요.
|
const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다.
|
||||||
|
${integratedProfile ? `
|
||||||
|
## 사용자 프로필 (${context.summaries.length}개 버전 통합)
|
||||||
|
${integratedProfile}
|
||||||
|
|
||||||
|
위 프로필들을 종합하여 사용자의 관심사, 맥락, 변화를 이해하고 개인화된 응답을 제공하세요.
|
||||||
|
최신 버전(높은 번호)의 정보를 우선시하되, 이전 버전의 맥락도 고려하세요.
|
||||||
` : ''}
|
` : ''}
|
||||||
- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요.
|
- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요.
|
||||||
- 최신 정보, 실시간 데이터, 현재 가격, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요.
|
- 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요.
|
||||||
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요.
|
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요.
|
||||||
- 도메인 관련 요청은 반드시 manage_domain 도구를 사용하세요.
|
- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요.
|
||||||
- manage_deposit, manage_domain 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요.
|
- 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요.
|
||||||
|
- 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요.
|
||||||
|
- manage_deposit, manage_domain, suggest_domains 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요.
|
||||||
- 응답은 간결하고 도움이 되도록 한국어로 작성하세요.`;
|
- 응답은 간결하고 도움이 되도록 한국어로 작성하세요.`;
|
||||||
|
|
||||||
const recentContext = context.recentMessages.slice(-10).map((m) => ({
|
const recentContext = context.recentMessages.slice(-10).map((m) => ({
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ 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;
|
NAMECHEAP_API_KEY?: string;
|
||||||
DOMAIN_OWNER_ID?: string;
|
DOMAIN_OWNER_ID?: string;
|
||||||
DEPOSIT_AGENT_ID?: string;
|
DEPOSIT_AGENT_ID?: string;
|
||||||
DEPOSIT_ADMIN_ID?: string;
|
DEPOSIT_ADMIN_ID?: string;
|
||||||
BRAVE_API_KEY?: string;
|
BRAVE_API_KEY?: string;
|
||||||
|
DEPOSIT_API_SECRET?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntentAnalysis {
|
export interface IntentAnalysis {
|
||||||
@@ -68,7 +68,8 @@ export interface Summary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversationContext {
|
export interface ConversationContext {
|
||||||
previousSummary: Summary | null;
|
previousSummary: Summary | null; // 최신 요약 (호환성 유지)
|
||||||
|
summaries: Summary[]; // 전체 요약 (최대 3개, 최신순)
|
||||||
recentMessages: BufferedMessage[];
|
recentMessages: BufferedMessage[];
|
||||||
totalMessages: number;
|
totalMessages: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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"
|
DOMAIN_OWNER_ID = "821596605"
|
||||||
DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E"
|
DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E"
|
||||||
DEPOSIT_ADMIN_ID = "821596605"
|
DEPOSIT_ADMIN_ID = "821596605"
|
||||||
@@ -28,4 +27,3 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
|||||||
# - BOT_TOKEN: Telegram Bot Token
|
# - BOT_TOKEN: Telegram Bot Token
|
||||||
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
|
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
|
||||||
# - OPENAI_API_KEY: OpenAI API 키
|
# - OPENAI_API_KEY: OpenAI API 키
|
||||||
# - NAMECHEAP_API_KEY: Namecheap API 서버 키 (Domain Agent용)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user