feat: 도메인 관리 기능 개선
- 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 <noreply@anthropic.com>
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -75,7 +75,8 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
|
|
||||||
- **Context7 API**: `lookup_docs` 함수로 라이브러리 문서 조회
|
- **Context7 API**: `lookup_docs` 함수로 라이브러리 문서 조회
|
||||||
- **Domain Agent**: `manage_domain` 함수 → OpenAI Assistants API (`asst_MzPFKoqt7V4w6bc0UwcXU4ob`)
|
- **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
|
- **wttr.in**: 날씨 API
|
||||||
- **DuckDuckGo**: 웹 검색 API
|
- **DuckDuckGo**: 웹 검색 API
|
||||||
- **Vault**: `vault.anvil.it.com`에서 API 키 중앙 관리
|
- **Vault**: `vault.anvil.it.com`에서 API 키 중앙 관리
|
||||||
@@ -102,3 +103,18 @@ deposit_transactions 검색 (pending 상태 + 입금자명 + 금액)
|
|||||||
|
|
||||||
**입금 계좌**: 하나은행 427-910018-27104 (주식회사 아이언클래드)
|
**입금 계좌**: 하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||||
- Vault 경로: `secret/companies/ironclad-corp`
|
- 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`인 도메인만 관리 가능
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -8,10 +8,11 @@
|
|||||||
2. [아키텍처](#아키텍처)
|
2. [아키텍처](#아키텍처)
|
||||||
3. [Function Calling](#function-calling)
|
3. [Function Calling](#function-calling)
|
||||||
4. [예치금 시스템](#예치금-시스템)
|
4. [예치금 시스템](#예치금-시스템)
|
||||||
5. [프로젝트 구조](#프로젝트-구조)
|
5. [도메인 관리](#도메인-관리)
|
||||||
6. [배포 가이드](#배포-가이드)
|
6. [프로젝트 구조](#프로젝트-구조)
|
||||||
7. [보안 설정](#보안-설정)
|
7. [배포 가이드](#배포-가이드)
|
||||||
8. [봇 명령어](#봇-명령어)
|
8. [보안 설정](#보안-설정)
|
||||||
|
9. [봇 명령어](#봇-명령어)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출
|
|||||||
| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 |
|
| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 |
|
||||||
| **계산** | "123 * 456", "100의 20%" | 내장 |
|
| **계산** | "123 * 456", "100의 20%" | 내장 |
|
||||||
| **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 |
|
| **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 |
|
||||||
| **도메인** | "도메인 목록", "anvil.it.com 네임서버" | Domain Agent (소유자 전용) |
|
| **도메인** | "도메인 목록", "anvil.it.com 네임서버", ".com 가격", "google.com whois" | Domain Agent + RDAP |
|
||||||
| **예치금** | "잔액 확인", "충전하고 싶어", "10000원 입금했어" | D1 + Email Worker |
|
| **예치금** | "잔액 확인", "충전하고 싶어", "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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -168,8 +168,9 @@ async function callNamecheapApi(funcName: string, funcArgs: Record<string, any>,
|
|||||||
const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
||||||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
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)) {
|
if (!allowedDomains.includes(funcArgs.domain)) {
|
||||||
return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` };
|
return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` };
|
||||||
}
|
}
|
||||||
@@ -250,6 +251,95 @@ async function callNamecheapApi(funcName: string, funcArgs: Record<string, any>,
|
|||||||
return fetch(`${apiUrl}/account/balance`, {
|
return fetch(`${apiUrl}/account/balance`, {
|
||||||
headers: { 'X-API-Key': apiKey },
|
headers: { 'X-API-Key': apiKey },
|
||||||
}).then(r => r.json());
|
}).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<string, string> = {
|
||||||
|
'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<string, any> = {
|
||||||
|
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:
|
default:
|
||||||
return { error: `Unknown function: ${funcName}` };
|
return { error: `Unknown function: ${funcName}` };
|
||||||
}
|
}
|
||||||
@@ -276,8 +366,16 @@ async function callDomainAgent(
|
|||||||
if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`;
|
if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`;
|
||||||
const thread = await threadRes.json() as { id: string };
|
const thread = await threadRes.json() as { id: string };
|
||||||
|
|
||||||
// 2. 메시지 추가 (허용 도메인 명시)
|
// 2. 메시지 추가 (허용 도메인 명시 + 응답 스타일 지시)
|
||||||
const domainList = allowedDomains.join(', ');
|
const domainList = allowedDomains.join(', ');
|
||||||
|
const instructions = `[시스템 지시]
|
||||||
|
- 관리 가능 도메인: ${domainList}
|
||||||
|
- 한국어로 질문하면 한국어로 답변하고, 가격은 원화(KRW)만 표시
|
||||||
|
- 영어로 질문하면 영어로 답변하고, 가격은 달러(USD)로 표시
|
||||||
|
- 가격 응답 시 불필요한 달러 환산 정보 생략
|
||||||
|
|
||||||
|
[사용자 질문]
|
||||||
|
${query}`;
|
||||||
await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -287,7 +385,7 @@ async function callDomainAgent(
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `[관리 가능 도메인: ${domainList}]\n\n${query}`
|
content: instructions
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user