From 42ab702d1cd36984a3ce4cc1e439bc05956fd8d4 Mon Sep 17 00:00:00 2001 From: kappa Date: Sun, 18 Jan 2026 11:15:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0=20+=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=95=9C=EA=B8=80=E2=86=92=EC=98=81=EB=AC=B8=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경: - Domain Agent 제거, 코드 직접 처리로 전환 - suggest_domains: 등록 가능 도메인만 표시, 10개 미만 시 재시도 - search_web: 한글 검색어 자동 영문 번역 (GPT-4o-mini) - WHOIS: raw 데이터 파싱으로 상세 정보 추출 - 가격 조회: API 필드명 수정 (register_krw → krw) - 동적 도구 로딩 시스템 추가 - 문서 정리 및 업데이트 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 207 ++++++++++-- README.md | 152 +++++++-- src/index.ts | 115 +++++++ src/openai-service.ts | 704 ++++++++++++++++++++++++++++++++--------- src/summary-service.ts | 102 ++++-- src/types.ts | 5 +- wrangler.toml | 2 - 7 files changed, 1056 insertions(+), 231 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b61f19..a899af3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,8 +181,9 @@ Telegram Webhook → Security Validation → Command/Message Router Command Handler AI Response Generator (commands.ts) (openai-service.ts) ↓ - Function Calling - (weather, search, time, calc, docs) + Function Calling (8개) + (weather, search, time, calc, docs, + domain, suggest_domains, deposit) ↓ Profile System (summary-service.ts) @@ -199,15 +200,16 @@ Telegram Webhook → Security Validation → Command/Message Router | `commands.ts` | 봇 명령어 | `handleCommand()` | | `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` | -**Function Calling Tools (7개):** +**Function Calling Tools (8개):** | 도구 | 함수명 | 외부 API | 트리거 키워드 | |------|--------|----------|---------------| | 날씨 | `get_weather` | wttr.in | 날씨 | -| 검색 | `web_search` | Brave Search | ~란, ~뭐야 | +| 검색 | `search_web` | Brave Search | ~란, ~뭐야 (한글→영문 자동 번역) | | 시간 | `get_current_time` | 내장 | 몇 시, 시간 | | 계산 | `calculate` | 내장 | 계산, +, -, *, / | | 문서 | `lookup_docs` | Context7 | 문서, 사용법, API | -| 도메인 | `manage_domain` | Domain Agent → Namecheap | 도메인, 네임서버, WHOIS | +| 도메인 | `manage_domain` | 코드 직접 처리 → Namecheap | 도메인, 네임서버, WHOIS | +| 도메인 추천 | `suggest_domains` | GPT + Namecheap | **도메인 추천, 도메인 제안, 도메인 아이디어** | | 예치금 | `manage_deposit` | Deposit Agent → D1 | **입금, 충전, 잔액, 계좌, 송금** | **Data Layer (D1 SQLite):** @@ -219,9 +221,31 @@ Telegram Webhook → Security Validation → Command/Message Router | `user_deposits` | 예치금 계정 | user_id, balance | | `deposit_transactions` | 거래 내역 | user_id, amount, status | | `bank_notifications` | SMS 파싱 | depositor_name, amount, bank | +| `user_domains` | 도메인 소유권 | user_id, domain, verified (등록 시 자동 추가) | **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 @@ -246,41 +270,74 @@ case 'new_tool': return await executeNewTool(args); ``` -### 프로필 시스템 흐름 +### 프로필 시스템 흐름 (3개 요약 통합 방식) ``` 메시지 수신 → message_buffer 저장 (최대 19개) ↓ 20개 도달 - 사용자 발언만 추출 → OpenAI 분석 - ↓ - summaries 테이블 저장 (generation++) - ↓ - 구버전 삭제 (최근 3개만 유지) + ┌──────────────┴──────────────┐ + ↓ ↓ +기존 요약 3개 조회 사용자 발언만 추출 + ↓ ↓ + └──────────→ OpenAI 통합 분석 ←┘ + ↓ + summaries 테이블 저장 (generation++) + ↓ + 구버전 삭제 (최근 3개만 유지) ``` +**통합 분석 방식:** +- 기존: 최신 요약 1개만 참조하여 업데이트 +- 변경: **모든 요약 (최대 3개) + 새 메시지 → AI가 통합 분석** + ### Context Enrichment ```typescript // getConversationContext() 반환값 구조 { - profile: "이전 프로필 요약", - recentMessages: [ /* 최근 10개 */ ] + previousSummary: Summary | null, // 최신 요약 (호환성) + summaries: Summary[], // 전체 요약 (최대 3개, 최신순) + recentMessages: BufferedMessage[], + totalMessages: number, } -// → AI 프롬프트의 system 메시지에 포함 +``` + +**시스템 프롬프트에 통합:** +``` +## 사용자 프로필 (3개 버전 통합) +[v1] 초기 프로필 내용... +[v2] 업데이트된 프로필... +[v3] 최신 프로필... + +최신 버전을 우선시하되, 이전 버전 맥락도 고려 ``` ### AI 시스템 프롬프트 (`summary-service.ts`) ``` -- 날씨, 시간, 계산, 검색 등의 요청은 제공된 도구를 사용하세요. +- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요. +- 최신 정보, 실시간 데이터, 현재 가격, 뉴스 등은 search_web 도구로 검색하세요. - 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. - 금액 제한이나 규칙을 직접 판단하지 마세요. -- 도메인 관련 요청은 반드시 manage_domain 도구를 사용하세요. -- manage_deposit, manage_domain 도구 결과는 그대로 전달하세요. - 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요. +- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. +- 기타 도메인 관련 요청(조회, 등록, 네임서버 등)은 manage_domain 도구를 사용하세요. +- manage_deposit, manage_domain, suggest_domains 도구 결과는 그대로 전달하세요. ``` **중요:** 메인 AI가 도구를 호출하지 않고 직접 답변하는 경우: 1. 시스템 프롬프트에 해당 키워드 추가 (`summary-service.ts:252-254`) 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 프롬프트 수정 방법 ```bash # Vault에서 API 키 조회 @@ -302,12 +359,12 @@ curl -X POST 'https://api.openai.com/v1/assistants/asst_XMoVGU7ZwRpUPI6PHGvRNm8E |------|--------|------| | `SUMMARY_THRESHOLD` | 20 | 프로필 업데이트 주기 (메시지 수) | | `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 | -| `DOMAIN_AGENT_ID` | - | 도메인 관리 Assistant ID | | `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID | | `DEPOSIT_AGENT_ID` | - | 예치금 관리 Assistant ID | | `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID | | `BANK_API_SECRET` | - | 입금 알림 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 | - | -| Domain Agent | 도메인 관리 | OpenAI Assistants | `asst_MzPFKoqt7V4w6bc0UwcXU4ob` | | Deposit Agent | 예치금 관리 | OpenAI Assistants | `asst_XMoVGU7ZwRpUPI6PHGvRNm8E` | | Namecheap API | 도메인 백엔드 | namecheap-api.anvil.it.com | 날짜: MM/DD/YYYY → ISO 변환 | | WHOIS API | WHOIS 조회 | whois-api-eight.vercel.app | ccSLD 미지원 | @@ -411,17 +467,102 @@ Content-Type: application/json ## Domain System -**도구 목록:** -| 도구 | 권한 | 설명 | -|------|------|------| -| `list_domains` | 소유자 | 도메인 목록 | -| `get_domain_info` | 소유자 | 상세 정보 (만료일 등) | -| `get_nameservers` | 공개 | 네임서버 조회 | -| `set_nameservers` | 소유자 | 네임서버 변경 | -| `get_price` | 공개 | TLD 가격 (원화) | -| `check_domains` | 공개 | 가용성 확인 | -| `whois_lookup` | 공개 | WHOIS 조회 | +**아키텍처 변경 (2025-01):** Agent 기반 → 코드 직접 처리 -**가격 정책:** 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 +``` diff --git a/README.md b/README.md index 2f37f66..4af20bf 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,10 @@ - **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원 - **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축 -- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리**, **예치금 관리** 등 AI가 자동으로 도구 호출 +- **Function Calling (8개)**: 날씨, 검색, 시간, 계산, 문서 조회, 도메인 관리, **도메인 추천**, 예치금 관리 - **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회 -- **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동 +- **동적 도구 로딩**: 메시지 키워드 기반으로 필요한 도구만 선택하여 토큰 절약 +- **도메인 추천**: GPT가 창의적 도메인 생성 → 가용성 자동 확인 → 가격과 함께 제안 - **Deposit Agent**: OpenAI Assistants API 기반 예치금 관리 에이전트 연동 - **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전 - **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리 @@ -40,9 +41,10 @@ | **D1** | SQLite 데이터베이스 | | **OpenAI** | GPT-4o-mini + Function Calling | | **Context7** | 라이브러리 문서 조회 API | -| **Domain Agent** | 도메인 관리 (OpenAI Assistants) | +| **도메인 관리** | 코드 직접 처리 → Namecheap API | +| **도메인 추천** | GPT + Namecheap API (코드 레벨) | | **Deposit Agent** | 예치금 관리 (OpenAI Assistants) | -| **Namecheap API** | 도메인 조회/관리 백엔드 | +| **Namecheap API** | 도메인 조회/가용성/가격 백엔드 | | **Email Workers** | SMS → 메일 파싱 (입금 알림) | | **Workers AI** | 폴백용 (Llama 3.1 8B) | @@ -67,16 +69,15 @@ │ (Function Call) │ 도구 호출 자동 판단 └──────────────────┘ │ - ┌───┴───┬───────┬───────┬───────┬───────┬───────┐ - ▼ ▼ ▼ ▼ ▼ ▼ ▼ -[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금] - │ │ │ │ │ │ │ - │ │ │ │ │ │ └── Deposit Agent (Assistants API) - │ │ │ │ │ │ ↓ - │ │ │ │ │ └── Domain Agent D1 (자동 매칭) - │ │ │ │ │ ↓ - │ │ │ │ └── Context7 Namecheap API - └───┬───┴───────┴───────┴───────┴───────────────────┘ + ┌───┴───┬───────┬───────┬───────┬───────┬───────┬───────┐ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ +[날씨] [검색] [시간] [계산] [문서] [도메인] [도메인 [예치금] + │ │ │ │ │ │ 추천] │ + │ │ │ │ │ │ │ └── Deposit Agent + │ │ │ │ │ │ └── GPT + Namecheap API + │ │ │ │ │ └── 코드 직접 처리 → Namecheap API + │ │ │ │ └── Context7 + └───┬───┴───────┴───────┴───────┴───────────────────────────┘ ▼ ┌──────────────────┐ │ 최종 응답 생성 │ @@ -97,18 +98,29 @@ └──────────────────┘ │ 20개 도달 ▼ -┌──────────────────┐ -│ 프로필 분석 │ ← 사용자 발언만 추출하여 분석 -│ (OpenAI) │ 봇 응답은 무시 -└──────────────────┘ +┌──────────────────────────────────────────┐ +│ 통합 프로필 분석 │ +│ ┌─────────────────┐ ┌───────────────┐ │ +│ │ 기존 요약 3개 │ │ 새 메시지 20개 │ │ +│ │ [v1][v2][v3] │ │ (사용자 발언) │ │ +│ └────────┬────────┘ └───────┬───────┘ │ +│ └──────────┬────────┘ │ +│ ↓ │ +│ OpenAI 통합 분석 │ +└──────────────────────────────────────────┘ │ ▼ ┌──────────────────┐ │ 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%" | 내장 | | **문서** | "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 | ### 동작 방식 @@ -329,7 +342,7 @@ function checkBankEmails() { ## 도메인 관리 -OpenAI Assistants API 기반 도메인 관리 에이전트입니다. +코드 직접 처리 방식의 도메인 관리 기능입니다. 메인 AI가 action 파라미터로 작업을 지정하고, 코드에서 Namecheap API를 호출합니다. ### 지원 기능 @@ -342,6 +355,7 @@ OpenAI Assistants API 기반 도메인 관리 에이전트입니다. | `가격 조회` | TLD/ccSLD 등록 가격 (원화) | 누구나 | | `WHOIS 조회` | 공개 WHOIS 정보 (RDAP) | 누구나 | | `가용성 확인` | 도메인 등록 가능 여부 | 누구나 | +| `도메인 등록` | 새 도메인 등록 (예치금 차감) | 사용자 | ### 가격 조회 @@ -370,6 +384,96 @@ Namecheap 가격 + 13% 마진, 매일 환율 업데이트 - **지원 TLD**: com, net, org, io, co, me, kr, jp, cn, uk, de, fr 등 40+ TLD - **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 wrangler secret put BRAVE_API_KEY + +# Deposit API Secret (namecheap-api 연동용) +wrangler secret put DEPOSIT_API_SECRET ``` ### Vault 연동 (선택) @@ -538,7 +645,6 @@ binding = "AI" SUMMARY_THRESHOLD = "20" MAX_SUMMARIES_PER_USER = "3" N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" -DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob" DOMAIN_OWNER_ID = "821596605" DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E" DEPOSIT_ADMIN_ID = "821596605" @@ -575,6 +681,8 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" | `/setup-webhook` | GET | Webhook 설정 | | `/webhook` | POST | Telegram Webhook | | `/api/bank-notification` | POST | 입금 알림 API (Apps Script 연동) | +| `/api/deposit/balance` | GET | 예치금 잔액 조회 (namecheap-api용) | +| `/api/deposit/deduct` | POST | 예치금 차감 (namecheap-api용) | --- diff --git a/src/index.ts b/src/index.ts index 057bc28..dbfc91c 100644 --- a/src/index.ts +++ b/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 처리 if (url.pathname === '/webhook') { // 보안 검증 diff --git a/src/openai-service.ts b/src/openai-service.ts index 6871a4a..3509b7a 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -119,16 +119,30 @@ const tools = [ type: 'function', function: { name: 'manage_domain', - description: '도메인을 관리합니다. 도메인 목록 조회, 도메인 정보 확인, 네임서버 조회/변경, 도메인 가용성 확인, 계정 잔액 조회, TLD 가격 조회 등을 수행할 수 있습니다.', + description: '도메인 관리 및 정보 조회. ".com 가격", ".io 가격" 같은 TLD 가격 조회, 도메인 등록, WHOIS 조회, 네임서버 관리 등을 처리합니다.', parameters: { type: 'object', properties: { - query: { + action: { 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 = { + 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 = { + 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(['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 { + 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(); + 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; + + // 등록 가능한 도메인만 추가 + 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 = {}; + 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로 필터링) -async function callNamecheapApi(funcName: string, funcArgs: Record, allowedDomains: string[]): Promise { +async function callNamecheapApi( + funcName: string, + funcArgs: Record, + allowedDomains: string[], + telegramUserId?: string, + db?: D1Database, + userId?: number +): Promise { const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e'; const apiUrl = 'https://namecheap-api.anvil.it.com'; @@ -322,130 +548,256 @@ async function callNamecheapApi(funcName: string, funcArgs: Record, 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: return { error: `Unknown function: ${funcName}` }; } } -// 도메인 에이전트 (Assistants API - 기존 Agent 활용) -async function callDomainAgent( - apiKey: string, - assistantId: string, - query: string, - allowedDomains: string[] = [] +// 도메인 작업 직접 실행 (Agent 없이 코드로 처리) +async function executeDomainAction( + action: string, + args: { domain?: string; nameservers?: string[]; tld?: string }, + allowedDomains: string[], + telegramUserId?: string, + db?: D1Database, + userId?: number ): Promise { - try { - // 1. Thread 생성 - const threadRes = await fetch('https://api.openai.com/v1/threads', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - 'OpenAI-Beta': 'assistants=v2', - }, - body: JSON.stringify({}), - }); - if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`; - const thread = await threadRes.json() as { id: string }; + const { domain, nameservers, tld } = args; - // 2. 메시지 추가 (허용 도메인 명시 + 응답 스타일 지시) - const domainList = allowedDomains.join(', '); - const instructions = `[시스템 지시] -- 관리 가능 도메인: ${domainList} -- 한국어로 질문하면 한국어로 답변하고, 가격은 원화(KRW)만 표시 -- 영어로 질문하면 영어로 답변하고, 가격은 달러(USD)로 표시 -- 가격 응답 시 불필요한 달러 환산 정보 생략 + switch (action) { + case 'list': { + const result = await callNamecheapApi('list_domains', {}, allowedDomains, telegramUserId, db, userId); + if (result.error) return `🚫 ${result.error}`; + if (!result.length) return '📋 등록된 도메인이 없습니다.'; + 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 - }), - }); + case 'info': { + if (!domain) return '🚫 도메인을 지정해주세요.'; + const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, telegramUserId, db, userId); + if (result.error) return `🚫 ${result.error}`; + return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`; + } - // 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 }; + case 'get_ns': { + if (!domain) return '🚫 도메인을 지정해주세요.'; + const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, telegramUserId, db, userId); + if (result.error) return `🚫 ${result.error}`; + const nsList = (result.nameservers || result).map((ns: string) => `• ${ns}`).join('\n'); + return `🌐 ${domain} 네임서버\n\n${nsList}`; + } - // 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 = []; + case 'set_ns': { + if (!domain) return '🚫 도메인을 지정해주세요.'; + if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.'; + if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`; + const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, telegramUserId, db, userId); + if (result.error) return `🚫 ${result.error}`; + return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`; + } - 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), - }); - } + case 'check': { + if (!domain) return '🚫 도메인을 지정해주세요.'; + 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}은 이미 등록된 도메인입니다.`; + } - // 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 }; + 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}`; } - await new Promise(resolve => setTimeout(resolve, 500)); - maxPolls--; + // 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 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 }; + 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(); } - 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 || '응답 없음'; + 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()}원/년`; } - return '도메인 에이전트 응답 없음'; - } catch (error) { - return `도메인 에이전트 오류: ${String(error)}`; + 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, env?: Env case 'search_web': { // Brave Search API - const query = args.query; + let query = args.query; try { if (!env?.BRAVE_API_KEY) { 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( - `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: { 'Accept': 'application/json', @@ -501,7 +893,12 @@ async function executeTool(name: string, args: Record, env?: Env `${i + 1}. ${r.title}\n ${r.description}\n ${r.url}` ).join('\n\n'); - return `🔍 검색 결과: ${query}\n\n${results}`; + // 번역된 경우 원본 쿼리도 표시 + const queryDisplay = (hasKorean && translatedQuery !== query) + ? `${query} (→ ${translatedQuery})` + : query; + + return `🔍 검색 결과: ${queryDisplay}\n\n${results}`; } catch (error) { return `검색 중 오류가 발생했습니다: ${String(error)}`; } @@ -613,9 +1010,27 @@ async function executeTool(name: string, args: Record, 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': { - const query = args.query; - console.log('[manage_domain] 시작:', { query, telegramUserId, hasDb: !!db }); + const { action, domain, nameservers, tld } = args; + console.log('[manage_domain] 시작:', { action, domain, telegramUserId, hasDb: !!db }); // 소유권 검증 (DB 조회) if (!telegramUserId || !db) { @@ -624,15 +1039,16 @@ async function executeTool(name: string, args: Record, env?: Env } let userDomains: string[] = []; + let userId: number | undefined; 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 '🚫 도메인 관리 권한이 없습니다.'; } + userId = user.id; // 사용자 소유 도메인 전체 목록 조회 const domains = await db.prepare( @@ -640,32 +1056,26 @@ async function executeTool(name: string, args: Record, env?: Env ).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 '🌐 도메인 관리 기능이 설정되지 않았습니다.'; - } + // 코드로 직접 처리 (Agent 없이) try { - console.log('[manage_domain] callDomainAgent 호출 시작'); - const result = await callDomainAgent(env.OPENAI_API_KEY, env.DOMAIN_AGENT_ID, query, userDomains); - console.log('[manage_domain] callDomainAgent 완료:', result?.slice(0, 100)); - // Markdown → HTML 변환 - const htmlResult = result - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/`(.+?)`/g, '$1'); - return `🌐 ${htmlResult}`; + const result = await executeDomainAction( + action, + { domain, nameservers, tld }, + userDomains, + telegramUserId, + db, + userId + ); + console.log('[manage_domain] 완료:', result?.slice(0, 100)); + return result; } catch (error) { - console.log('[manage_domain] callDomainAgent 오류:', error); - return `🌐 도메인 관리 오류: ${String(error)}`; + console.log('[manage_domain] 오류:', error); + return `🚫 도메인 관리 오류: ${String(error)}`; } } @@ -678,7 +1088,7 @@ async function executeTool(name: string, args: Record, env?: Env async function callOpenAI( apiKey: string, messages: OpenAIMessage[], - useTools: boolean = true + selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용 ): Promise { const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -689,8 +1099,8 @@ async function callOpenAI( body: JSON.stringify({ model: 'gpt-4o-mini', messages, - tools: useTools ? tools : undefined, - tool_choice: useTools ? 'auto' : undefined, + tools: selectedTools?.length ? selectedTools : undefined, + tool_choice: selectedTools?.length ? 'auto' : undefined, max_tokens: 1000, }), }); @@ -725,10 +1135,16 @@ export async function generateOpenAIResponse( { 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; + 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회 반복) let iterations = 0; while (assistantMessage.tool_calls && iterations < 3) { @@ -754,12 +1170,14 @@ export async function generateOpenAIResponse( }); 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; } - return assistantMessage.content || '응답을 생성할 수 없습니다.'; + const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.'; + + return finalResponse; } // 프로필 생성용 (도구 없이) @@ -774,7 +1192,7 @@ export async function generateProfileWithOpenAI( const response = await callOpenAI( env.OPENAI_API_KEY, [{ role: 'user', content: prompt }], - false + undefined // 도구 없이 호출 ); return response.choices[0].message.content || '프로필 생성 실패'; diff --git a/src/summary-service.ts b/src/summary-service.ts index 8639a15..ee52288 100644 --- a/src/summary-service.ts +++ b/src/summary-service.ts @@ -69,30 +69,52 @@ export async function getLatestSummary( return summary || null; } +// 모든 요약 조회 (최대 3개, 최신순) +export async function getAllSummaries( + db: D1Database, + userId: number, + chatId: string +): Promise { + 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( db: D1Database, userId: number, chatId: string ): Promise { - const [previousSummary, recentMessages] = await Promise.all([ - getLatestSummary(db, userId, chatId), + const [summaries, recentMessages] = await Promise.all([ + getAllSummaries(db, userId, chatId), getBufferedMessages(db, userId, chatId), ]); + const previousSummary = summaries[0] || null; // 최신 요약 (호환성) const totalMessages = (previousSummary?.message_count || 0) + recentMessages.length; return { previousSummary, + summaries, recentMessages, totalMessages, }; } -// AI 요약 생성 +// AI 요약 생성 (모든 요약 통합) async function generateSummary( env: Env, - previousSummary: string | null, + allSummaries: Summary[], messages: BufferedMessage[] ): Promise { // 사용자 메시지만 추출 @@ -106,29 +128,37 @@ async function generateSummary( let prompt: string; - if (previousSummary) { - prompt = `당신은 사용자 프로필 분석 전문가입니다. -기존 사용자 프로필과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요. + if (allSummaries.length > 0) { + // 모든 기존 프로필 통합 (오래된 것부터) + const existingProfiles = allSummaries + .slice() + .reverse() + .map((s) => `[v${s.generation}] ${s.summary}`) + .join('\n\n'); -## 기존 사용자 프로필 -${previousSummary} + prompt = `당신은 사용자 프로필 분석 전문가입니다. +기존 사용자 프로필들과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요. + +## 기존 사용자 프로필 (${allSummaries.length}개 버전) +${existingProfiles} ## 새로운 사용자 발언 (${userMsgCount}개) ${userMessages} ## 요구사항 -1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석 -2. **의미 있는 정보 추출**: +1. **통합 분석**: 모든 기존 프로필을 종합하고 새로운 정보를 추가 +2. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석 +3. **의미 있는 정보 추출**: - 사용자의 관심사, 취미, 선호도 - 질문한 주제들 (무엇에 대해 알고 싶어하는지) - 요청사항, 목표, 해결하려는 문제 - 개인적 맥락 (직업, 상황, 배경 등) - - 감정 상태나 태도 변화 -3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략 -4. **간결하게**: 300-400자 이내 -5. **한국어로 작성** + - 관심사 변화 추이 +4. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략 +5. **간결하게**: 400-500자 이내 +6. **한국어로 작성** -업데이트된 사용자 프로필:`; +통합된 사용자 프로필:`; } else { prompt = `당신은 사용자 프로필 분석 전문가입니다. 대화 내용에서 사용자에 대한 정보를 추출하여 프로필을 작성하세요. @@ -200,17 +230,19 @@ export async function processAndSummarize( 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( env, - previousSummary?.summary || null, + allSummaries, messages ); - const newGeneration = (previousSummary?.generation || 0) + 1; - const newMessageCount = (previousSummary?.message_count || 0) + messages.length; + const newGeneration = (latestSummary?.generation || 0) + 1; + const newMessageCount = (latestSummary?.message_count || 0) + messages.length; // 트랜잭션 실행 await env.DB.batch([ @@ -242,18 +274,30 @@ export async function generateAIResponse( ): Promise { const context = await getConversationContext(env.DB, userId, chatId); - const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다. -${context.previousSummary ? ` -## 사용자 프로필 -${context.previousSummary.summary} + // 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시) + const integratedProfile = context.summaries.length > 0 + ? context.summaries + .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_domain 도구를 사용하세요. -- manage_deposit, manage_domain 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요. +- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요. +- 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요. +- 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요. +- manage_deposit, manage_domain, suggest_domains 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요. - 응답은 간결하고 도움이 되도록 한국어로 작성하세요.`; const recentContext = context.recentMessages.slice(-10).map((m) => ({ diff --git a/src/types.ts b/src/types.ts index 423d9ec..d7cdd6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,12 +7,12 @@ export interface Env { MAX_SUMMARIES_PER_USER?: string; N8N_WEBHOOK_URL?: string; OPENAI_API_KEY?: string; - DOMAIN_AGENT_ID?: string; NAMECHEAP_API_KEY?: string; DOMAIN_OWNER_ID?: string; DEPOSIT_AGENT_ID?: string; DEPOSIT_ADMIN_ID?: string; BRAVE_API_KEY?: string; + DEPOSIT_API_SECRET?: string; } export interface IntentAnalysis { @@ -68,7 +68,8 @@ export interface Summary { } export interface ConversationContext { - previousSummary: Summary | null; + previousSummary: Summary | null; // 최신 요약 (호환성 유지) + summaries: Summary[]; // 전체 요약 (최대 3개, 최신순) recentMessages: BufferedMessage[]; totalMessages: number; } diff --git a/wrangler.toml b/wrangler.toml index 808e5ea..caaa418 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,7 +9,6 @@ binding = "AI" SUMMARY_THRESHOLD = "20" MAX_SUMMARIES_PER_USER = "3" N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" -DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob" DOMAIN_OWNER_ID = "821596605" DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E" DEPOSIT_ADMIN_ID = "821596605" @@ -28,4 +27,3 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" # - BOT_TOKEN: Telegram Bot Token # - WEBHOOK_SECRET: Webhook 검증용 시크릿 # - OPENAI_API_KEY: OpenAI API 키 -# - NAMECHEAP_API_KEY: Namecheap API 서버 키 (Domain Agent용)