From 42c57747a7e44178180fae440aaf9ade00120baf Mon Sep 17 00:00:00 2001 From: kappa Date: Fri, 16 Jan 2026 13:10:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_price: ccSLD(it.com, uk.com 등) 가격 조회 지원 - check_domains: 도메인 가용성 확인 기능 추가 - whois_lookup: 공개 RDAP API로 WHOIS 조회 (com/net/org/io/me/info/biz) - 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능 - 한국어 질문 시 원화(KRW)만 표시하도록 개선 - README.md, CLAUDE.md 도메인 관리 문서 추가 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 18 ++++++- README.md | 54 +++++++++++++++++++-- src/openai-service.ts | 106 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 168 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 797b403..3bd99aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ Telegram Webhook → Security Validation → Command/Message Router - **Context7 API**: `lookup_docs` 함수로 라이브러리 문서 조회 - **Domain Agent**: `manage_domain` 함수 → OpenAI Assistants API (`asst_MzPFKoqt7V4w6bc0UwcXU4ob`) -- **Namecheap API**: `https://namecheap-api.anvil.it.com` (Domain Agent 백엔드) +- **Namecheap API**: `https://namecheap-api.anvil.it.com` (도메인 목록, 가격, 네임서버) +- **RDAP API**: 공개 WHOIS 조회 (com/net/org/io/me/info/biz TLD 지원) - **wttr.in**: 날씨 API - **DuckDuckGo**: 웹 검색 API - **Vault**: `vault.anvil.it.com`에서 API 키 중앙 관리 @@ -102,3 +103,18 @@ deposit_transactions 검색 (pending 상태 + 입금자명 + 금액) **입금 계좌**: 하나은행 427-910018-27104 (주식회사 아이언클래드) - Vault 경로: `secret/companies/ironclad-corp` + +## Domain System + +**Domain Agent 도구 (OpenAI Assistant)**: +- `list_domains` - 소유 도메인 목록 (권한 필요) +- `get_domain_info` - 도메인 상세 정보 (권한 필요) +- `get_nameservers` - 네임서버 조회 (누구나) +- `set_nameservers` - 네임서버 변경 (권한 필요) +- `get_price` - TLD/ccSLD 가격 조회 (누구나, 원화 표시) +- `check_domains` - 도메인 가용성 확인 (누구나) +- `whois_lookup` - 공개 WHOIS/RDAP 조회 (누구나) + +**가격 정책**: Namecheap 원가 + 13%, 매일 환율 업데이트 (USD→KRW) + +**권한 체크**: `user_domains` 테이블에서 `verified=1`인 도메인만 관리 가능 diff --git a/README.md b/README.md index c08f504..a65ee55 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ 2. [아키텍처](#아키텍처) 3. [Function Calling](#function-calling) 4. [예치금 시스템](#예치금-시스템) -5. [프로젝트 구조](#프로젝트-구조) -6. [배포 가이드](#배포-가이드) -7. [보안 설정](#보안-설정) -8. [봇 명령어](#봇-명령어) +5. [도메인 관리](#도메인-관리) +6. [프로젝트 구조](#프로젝트-구조) +7. [배포 가이드](#배포-가이드) +8. [보안 설정](#보안-설정) +9. [봇 명령어](#봇-명령어) --- @@ -130,7 +131,7 @@ OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출 | **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 | | **계산** | "123 * 456", "100의 20%" | 내장 | | **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 | -| **도메인** | "도메인 목록", "anvil.it.com 네임서버" | Domain Agent (소유자 전용) | +| **도메인** | "도메인 목록", "anvil.it.com 네임서버", ".com 가격", "google.com whois" | Domain Agent + RDAP | | **예치금** | "잔액 확인", "충전하고 싶어", "10000원 입금했어" | D1 + Email Worker | ### 동작 방식 @@ -230,6 +231,49 @@ Cloudflare Email Routing으로 SMS를 메일로 전달받아 파싱합니다. --- +## 도메인 관리 + +OpenAI Assistants API 기반 도메인 관리 에이전트입니다. + +### 지원 기능 + +| 기능 | 설명 | 권한 | +|------|------|------| +| `도메인 목록` | 내 도메인 목록 조회 | 소유자 | +| `도메인 정보` | 도메인 상세 정보 (만료일 등) | 소유자 | +| `네임서버 조회` | 현재 네임서버 확인 | 누구나 | +| `네임서버 변경` | 네임서버 설정 변경 | 소유자 | +| `가격 조회` | TLD/ccSLD 등록 가격 (원화) | 누구나 | +| `WHOIS 조회` | 공개 WHOIS 정보 (RDAP) | 누구나 | +| `가용성 확인` | 도메인 등록 가능 여부 | 누구나 | + +### 가격 조회 + +Namecheap 가격 + 13% 마진, 매일 환율 업데이트 + +``` +사용자: ".com 가격" +봇: ".com 도메인 등록 가격은 20,000원입니다." + +사용자: "it.com 가격" +봇: "it.com 도메인 등록 가격은 55,000원입니다." +``` + +지원 TLD: com, net, org, io, me, info, biz, it.com, uk.com 등 + +### WHOIS 조회 + +공개 RDAP API를 통해 아무 도메인이나 조회 가능 + +``` +사용자: "google.com whois" +봇: 등록일, 만료일, 네임서버, 등록기관 정보 표시 +``` + +지원 TLD: com, net, org, io, me, info, biz (RDAP 지원 TLD) + +--- + ## 프로젝트 구조 ``` diff --git a/src/openai-service.ts b/src/openai-service.ts index 0a3ee42..e76bdd2 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -168,8 +168,9 @@ async function callNamecheapApi(funcName: string, funcArgs: Record, const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e'; const apiUrl = 'https://namecheap-api.anvil.it.com'; - // 도메인 권한 체크 - if (['get_domain_info', 'get_nameservers', 'set_nameservers', 'create_child_ns', 'get_child_ns', 'delete_child_ns'].includes(funcName)) { + // 도메인 권한 체크 (쓰기 작업만) + // 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능 + if (['set_nameservers', 'create_child_ns', 'delete_child_ns'].includes(funcName)) { if (!allowedDomains.includes(funcArgs.domain)) { return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` }; } @@ -250,6 +251,95 @@ async function callNamecheapApi(funcName: string, funcArgs: Record, return fetch(`${apiUrl}/account/balance`, { headers: { 'X-API-Key': apiKey }, }).then(r => r.json()); + case 'get_price': { + const tld = funcArgs.tld?.replace(/^\./, ''); // .com → com + return fetch(`${apiUrl}/prices/${tld}`, { + headers: { 'X-API-Key': apiKey }, + }).then(r => r.json()); + } + case 'check_domains': { + return fetch(`${apiUrl}/domains/check`, { + method: 'POST', + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ domains: funcArgs.domains }), + }).then(r => r.json()); + } + case 'whois_lookup': { + // 공개 RDAP API 사용 (누구나 조회 가능) + const domain = funcArgs.domain; + try { + // TLD별 RDAP 서버 (먼저 직접 시도) + const tld = domain.split('.').pop()?.toLowerCase(); + const rdapServers: Record = { + 'com': 'https://rdap.verisign.com/com/v1', + 'net': 'https://rdap.verisign.com/net/v1', + 'org': 'https://rdap.publicinterestregistry.org/rdap', + 'io': 'https://rdap.nic.io', + 'me': 'https://rdap.nic.me', + 'info': 'https://rdap.afilias.net/rdap/info', + 'biz': 'https://rdap.nic.biz', + }; + + let rdapRes: Response | null = null; + + // 1. TLD별 직접 서버 시도 + if (tld && rdapServers[tld]) { + try { + rdapRes = await fetch(`${rdapServers[tld]}/domain/${domain}`); + } catch { + rdapRes = null; + } + } + + // 2. 실패하면 rdap.org 프록시 시도 (리다이렉트 따라가기) + if (!rdapRes || !rdapRes.ok) { + const proxyRes = await fetch(`https://rdap.org/domain/${domain}`, { redirect: 'manual' }); + if (proxyRes.status === 302 || proxyRes.status === 301) { + const location = proxyRes.headers.get('location'); + if (location) { + rdapRes = await fetch(location); + } + } else if (proxyRes.ok) { + rdapRes = proxyRes; + } + } + + if (!rdapRes || !rdapRes.ok) { + return { + error: `WHOIS 조회 실패: .${tld} TLD는 RDAP를 지원하지 않습니다.`, + suggestion: '지원 TLD: com, net, org, io, me, info, biz' + }; + } + const rdap = await rdapRes.json() as any; + + // RDAP 응답 파싱 + const result: Record = { + domain: rdap.ldhName || domain, + status: rdap.status || [], + }; + + // 등록일/만료일 + for (const event of rdap.events || []) { + if (event.eventAction === 'registration') result.created = event.eventDate; + if (event.eventAction === 'expiration') result.expires = event.eventDate; + if (event.eventAction === 'last changed') result.updated = event.eventDate; + } + + // 네임서버 + result.nameservers = (rdap.nameservers || []).map((ns: any) => ns.ldhName).filter(Boolean); + + // 등록기관 + for (const entity of rdap.entities || []) { + if (entity.roles?.includes('registrar')) { + result.registrar = entity.vcardArray?.[1]?.find((v: any) => v[0] === 'fn')?.[3] || entity.handle; + } + } + + return result; + } catch (error) { + return { error: `WHOIS 조회 오류: ${String(error)}` }; + } + } default: return { error: `Unknown function: ${funcName}` }; } @@ -276,8 +366,16 @@ async function callDomainAgent( if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`; const thread = await threadRes.json() as { id: string }; - // 2. 메시지 추가 (허용 도메인 명시) + // 2. 메시지 추가 (허용 도메인 명시 + 응답 스타일 지시) const domainList = allowedDomains.join(', '); + const instructions = `[시스템 지시] +- 관리 가능 도메인: ${domainList} +- 한국어로 질문하면 한국어로 답변하고, 가격은 원화(KRW)만 표시 +- 영어로 질문하면 영어로 답변하고, 가격은 달러(USD)로 표시 +- 가격 응답 시 불필요한 달러 환산 정보 생략 + +[사용자 질문] +${query}`; await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, { method: 'POST', headers: { @@ -287,7 +385,7 @@ async function callDomainAgent( }, body: JSON.stringify({ role: 'user', - content: `[관리 가능 도메인: ${domainList}]\n\n${query}` + content: instructions }), });