From f9f577f25cc6c3515ccd5d40bca62bdee25f7f35 Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 5 Feb 2026 09:57:32 +0900 Subject: [PATCH] feat: implement processDomainConsultation main handler - Add full conversation flow with session management - Handle tool call execution - Support __PASSTHROUGH__ and __SESSION_END__ markers - Add hasDomainSession helper for routing - Export executeDomainAction from domain-tool.ts Co-Authored-By: Claude Opus 4.5 --- .../2026-02-05-agent-refactoring-design.md | 234 ++++ ...-02-05-agent-refactoring-implementation.md | 1093 +++++++++++++++++ src/agents/domain-agent.ts | 95 +- src/tools/domain-tool.ts | 2 +- 4 files changed, 1417 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-02-05-agent-refactoring-design.md create mode 100644 docs/plans/2026-02-05-agent-refactoring-implementation.md diff --git a/docs/plans/2026-02-05-agent-refactoring-design.md b/docs/plans/2026-02-05-agent-refactoring-design.md new file mode 100644 index 0000000..69049b8 --- /dev/null +++ b/docs/plans/2026-02-05-agent-refactoring-design.md @@ -0,0 +1,234 @@ +# 도메인/예치금 에이전트 리팩토링 설계 + +> 작성일: 2026-02-05 +> 상태: 설계 완료, 구현 대기 + +## 개요 + +기존 직접 코드 처리 방식의 `domain-tool.ts`와 `deposit-agent.ts`를 `server-agent.ts` 패턴의 세션 기반 AI 에이전트로 리팩토링한다. + +## 요구사항 + +| 항목 | 도메인 에이전트 | 예치금 에이전트 | +|------|----------------|----------------| +| **트리거** | 모든 도메인 요청 | 모든 예치금 요청 | +| **의도 파악** | AI가 판단 | AI가 판단 | +| **세션** | 작업 단위 (추천/등록/NS변경) | 입금 신고만 | +| **즉시 응답** | 가격, WHOIS, 목록 조회 | 잔액, 내역 조회 | +| **리팩토링** | domain-tool.ts 통합 | deposit-agent.ts 통합 | + +## 아키텍처 + +``` +메인 AI (openai-service.ts) + │ + ├─ 도메인 키워드 감지 → Domain Agent (domain-agent.ts) + │ ├─ 세션 있음? → 세션 컨텍스트로 처리 + │ ├─ 세션 필요? → 세션 생성 후 상담 + │ └─ 즉시 응답 가능? → 바로 실행 + │ + └─ 예치금 키워드 감지 → Deposit Agent (deposit-agent.ts) + ├─ 세션 있음? → 입금 신고 흐름 계속 + ├─ 입금 신고? → 세션 생성 후 정보 수집 + └─ 조회 요청? → 바로 실행 + +__PASSTHROUGH__: 무관한 메시지는 메인 AI로 반환 +``` + +## 도메인 에이전트 상세 + +### 세션 상태 +```typescript +type DomainSessionStatus = + | 'gathering' // 정보 수집 중 (추천용 키워드, 용도) + | 'suggesting' // 추천 결과 표시 중 + | 'confirming' // 등록 확인 대기 + | 'setting_ns' // 네임서버 변경 확인 대기 + | 'completed'; // 완료 +``` + +### 작업별 흐름 + +| 작업 | 세션 | 흐름 | +|------|------|------| +| **추천** | ✅ | gathering → suggesting → confirming → completed | +| **등록** | ✅ | confirming → completed (잔액 확인 → 결제) | +| **NS 변경** | ✅ | setting_ns → completed (위험 작업이라 확인) | +| **가격 조회** | ❌ | 즉시 응답 | +| **WHOIS** | ❌ | 즉시 응답 | +| **내 도메인 목록** | ❌ | 즉시 응답 | +| **도메인 정보** | ❌ | 즉시 응답 | + +### AI 페르소나 +``` +"10년 경력의 도메인 컨설턴트. 브랜딩, SEO, 가격 대비 가치를 고려한 조언 제공. +불필요한 프리미엄 도메인 추천 자제, 실용적인 선택 유도." +``` + +### 전용 도구 +- `check_domain`: 가용성 + 가격 확인 +- `search_suggestions`: 키워드 기반 도메인 추천 +- `get_whois`: WHOIS 조회 +- `get_price`: TLD별 가격 조회 +- `register_domain`: 도메인 등록 (잔액 차감) +- `set_nameservers`: 네임서버 변경 + +## 예치금 에이전트 상세 + +### 세션 상태 +```typescript +type DepositSessionStatus = + | 'collecting_amount' // 금액 수집 중 + | 'collecting_name' // 입금자명 수집 중 + | 'confirming' // 입금 신고 확인 대기 + | 'completed'; // 완료 +``` + +### 작업별 흐름 + +| 작업 | 세션 | 흐름 | +|------|------|------| +| **입금 신고** | ✅ | collecting_amount → collecting_name → confirming → completed | +| **잔액 조회** | ❌ | 즉시 응답 | +| **계좌 안내** | ❌ | 즉시 응답 | +| **거래 내역** | ❌ | 즉시 응답 | +| **거래 취소** | ❌ | 즉시 응답 (본인 pending만) | +| **관리자 기능** | ❌ | 즉시 응답 (pending/confirm/reject) | + +### 스마트 파싱 +``` +"홍길동 5만원 입금" → 금액 + 입금자명 한번에 파싱 → confirming 직행 +"충전할게" → "얼마를 충전하시겠어요?" → collecting_amount +"3만원" → "입금자명을 알려주세요" → collecting_name +"홍길동" → confirming → 자동매칭 시도 +``` + +### AI 페르소나 +``` +"친절한 금융 상담사. 입금 절차를 명확하게 안내하고, +자동 매칭 결과를 즉시 알려줌. 오입금 방지를 위해 확인 절차 진행." +``` + +### 전용 도구 +- `get_balance`: 잔액 조회 +- `get_account_info`: 계좌 안내 +- `request_deposit`: 입금 신고 및 자동 매칭 +- `get_transactions`: 거래 내역 +- `cancel_transaction`: 대기 중 거래 취소 + +## 데이터베이스 스키마 + +### domain_sessions +```sql +CREATE TABLE domain_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + collected_info TEXT, + target_domain TEXT, + messages TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX idx_domain_sessions_expires ON domain_sessions(expires_at); +``` + +### deposit_sessions +```sql +CREATE TABLE deposit_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + collected_info TEXT, + messages TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX idx_deposit_sessions_expires ON deposit_sessions(expires_at); +``` + +## 파일 구조 변경 + +### Before +``` +src/ +├── openai-service.ts +├── deposit-agent.ts +├── server-agent.ts +├── tools/ +│ ├── domain-tool.ts +│ ├── deposit-tool.ts +│ ├── server-tool.ts +│ └── ... +``` + +### After +``` +src/ +├── openai-service.ts +├── agents/ +│ ├── domain-agent.ts +│ ├── deposit-agent.ts +│ └── server-agent.ts +├── tools/ +│ ├── search-tool.ts +│ ├── weather-tool.ts +│ └── utility-tools.ts +``` + +## 구현 순서 + +### Phase 1: 기반 작업 +1. `migrations/006_add_agent_sessions.sql` 작성 +2. `src/agents/` 디렉토리 생성 +3. `server-agent.ts` → `src/agents/`로 이동 +4. 타입 정의 (`types.ts`에 세션 타입 추가) + +### Phase 2: 도메인 에이전트 +1. `src/agents/domain-agent.ts` 생성 +2. 기존 `domain-tool.ts` 로직 통합 +3. `openai-service.ts` 수정 +4. `tools/domain-tool.ts` 삭제 + +### Phase 3: 예치금 에이전트 +1. `src/agents/deposit-agent.ts` 리팩토링 +2. `openai-service.ts` 수정 +3. `tools/deposit-tool.ts` 삭제 + +### Phase 4: 정리 +1. `tools/server-tool.ts` → `agents/` 통합 +2. import 경로 정리 +3. 테스트 및 문서 업데이트 + +## 테스트 계획 + +### 도메인 에이전트 +| 시나리오 | 예상 결과 | +|----------|----------| +| "도메인 추천해줘" | 세션 시작 → 용도 질문 | +| "커피숍 도메인" | 키워드 수집 → 추천 결과 | +| "1번 등록" | 잔액 확인 → 등록 확인 | +| ".com 가격" | 즉시 응답 (세션 없음) | +| "example.com WHOIS" | 즉시 응답 (세션 없음) | +| 세션 중 "날씨 알려줘" | `__PASSTHROUGH__` → 메인 AI | + +### 예치금 에이전트 +| 시나리오 | 예상 결과 | +|----------|----------| +| "충전할게" | 세션 시작 → 금액 질문 | +| "5만원" | 입금자명 질문 | +| "홍길동" | 확인 → 자동매칭 시도 | +| "홍길동 5만원 입금" | 한번에 파싱 → 바로 확인 | +| "잔액" | 즉시 응답 (세션 없음) | +| "내역" | 즉시 응답 (세션 없음) | + +## 위험 요소 및 대응 + +| 위험 | 대응 | +|------|------| +| 기존 기능 회귀 | 기존 테스트 유지 + 세션 테스트 추가 | +| 세션 충돌 | user_id 기반 단일 세션 보장 | +| AI 오판단 | `__PASSTHROUGH__` 폴백, 명확한 시스템 프롬프트 | +| 마이그레이션 실패 | 로컬 테스트 후 프로덕션 적용 | diff --git a/docs/plans/2026-02-05-agent-refactoring-implementation.md b/docs/plans/2026-02-05-agent-refactoring-implementation.md new file mode 100644 index 0000000..896d128 --- /dev/null +++ b/docs/plans/2026-02-05-agent-refactoring-implementation.md @@ -0,0 +1,1093 @@ +# 도메인/예치금 에이전트 리팩토링 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** domain-tool.ts와 deposit-agent.ts를 server-agent 패턴의 세션 기반 AI 에이전트로 리팩토링 + +**Architecture:** 모든 도메인/예치금 요청이 에이전트를 통과하고 AI가 의도를 파악. 도메인은 작업 단위 세션, 예치금은 입금 신고만 세션 유지. + +**Tech Stack:** TypeScript, Cloudflare Workers, D1, OpenAI GPT-4o-mini + +--- + +## Phase 1: 기반 작업 + +### Task 1.1: 마이그레이션 SQL 작성 + +**Files:** +- Create: `migrations/006_add_agent_sessions.sql` + +**Step 1: 마이그레이션 파일 작성** + +```sql +-- 006_add_agent_sessions.sql +-- Domain Agent Sessions +CREATE TABLE IF NOT EXISTS domain_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('gathering', 'suggesting', 'confirming', 'setting_ns', 'completed')), + collected_info TEXT, -- JSON: {keywords, purpose, suggestions[]} + target_domain TEXT, -- 등록/NS변경 대상 도메인 + messages TEXT, -- JSON: 대화 히스토리 + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL -- TTL 1시간 +); + +CREATE INDEX IF NOT EXISTS idx_domain_sessions_expires ON domain_sessions(expires_at); + +-- Deposit Agent Sessions +CREATE TABLE IF NOT EXISTS deposit_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('collecting_amount', 'collecting_name', 'confirming', 'completed')), + collected_info TEXT, -- JSON: {amount, depositor_name} + messages TEXT, -- JSON: 대화 히스토리 + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL -- TTL 30분 +); + +CREATE INDEX IF NOT EXISTS idx_deposit_sessions_expires ON deposit_sessions(expires_at); +``` + +**Step 2: 로컬 D1에 적용 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && wrangler d1 execute telegram-conversations --local --file=migrations/006_add_agent_sessions.sql` +Expected: 테이블 생성 성공 + +**Step 3: 커밋** + +```bash +git add migrations/006_add_agent_sessions.sql +git commit -m "chore: add domain/deposit agent session tables migration" +``` + +--- + +### Task 1.2: agents 디렉토리 생성 및 server-agent 이동 + +**Files:** +- Create: `src/agents/` 디렉토리 +- Move: `src/server-agent.ts` → `src/agents/server-agent.ts` +- Modify: `src/openai-service.ts` (import 경로 수정) +- Modify: `src/tools/server-tool.ts` (import 경로 수정) +- Modify: `src/index.ts` (import 경로 수정) + +**Step 1: 디렉토리 생성 및 파일 이동** + +```bash +mkdir -p src/agents +mv src/server-agent.ts src/agents/server-agent.ts +``` + +**Step 2: openai-service.ts import 수정** + +기존: +```typescript +import { getServerSession, processServerConsultation } from './server-agent'; +``` + +변경: +```typescript +import { getServerSession, processServerConsultation } from './agents/server-agent'; +``` + +**Step 3: tools/server-tool.ts import 수정** + +기존: +```typescript +import { getServerSession, saveServerSession, deleteServerSession } from '../server-agent'; +``` + +변경: +```typescript +import { getServerSession, saveServerSession, deleteServerSession } from '../agents/server-agent'; +``` + +**Step 4: index.ts import 수정 (있다면)** + +필요 시 import 경로 수정 + +**Step 5: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run dev` +Expected: 에러 없이 실행 + +**Step 6: 커밋** + +```bash +git add src/agents/ src/openai-service.ts src/tools/server-tool.ts +git commit -m "refactor: move server-agent to agents directory" +``` + +--- + +### Task 1.3: troubleshoot-agent도 agents로 이동 + +**Files:** +- Move: `src/troubleshoot-agent.ts` → `src/agents/troubleshoot-agent.ts` +- Modify: `src/openai-service.ts` (import 경로 수정) +- Modify: `src/tools/troubleshoot-tool.ts` (import 경로 수정) + +**Step 1: 파일 이동** + +```bash +mv src/troubleshoot-agent.ts src/agents/troubleshoot-agent.ts +``` + +**Step 2: openai-service.ts import 수정** + +기존: +```typescript +import { getTroubleshootSession, processTroubleshoot } from './troubleshoot-agent'; +``` + +변경: +```typescript +import { getTroubleshootSession, processTroubleshoot } from './agents/troubleshoot-agent'; +``` + +**Step 3: tools/troubleshoot-tool.ts import 수정** + +필요 시 import 경로 수정 + +**Step 4: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run dev` +Expected: 에러 없이 실행 + +**Step 5: 커밋** + +```bash +git add src/agents/troubleshoot-agent.ts src/openai-service.ts src/tools/troubleshoot-tool.ts +git commit -m "refactor: move troubleshoot-agent to agents directory" +``` + +--- + +### Task 1.4: 타입 정의 추가 (types.ts) + +**Files:** +- Modify: `src/types.ts` + +**Step 1: DomainSession 타입 추가** + +```typescript +// Domain Agent Session +export interface DomainSession { + telegramUserId: string; + status: 'gathering' | 'suggesting' | 'confirming' | 'setting_ns' | 'completed'; + collectedInfo: { + keywords?: string; + purpose?: string; // 블로그, 쇼핑몰, 브랜드 등 + suggestions?: Array<{ + domain: string; + price: number; + available: boolean; + }>; + }; + targetDomain?: string; // 등록/NS변경 대상 + targetNameservers?: string[]; // NS변경 시 새 네임서버 + messages: Array<{ role: 'user' | 'assistant'; content: string }>; + createdAt: number; + updatedAt: number; +} + +// Deposit Agent Session +export interface DepositSession { + telegramUserId: string; + status: 'collecting_amount' | 'collecting_name' | 'confirming' | 'completed'; + collectedInfo: { + amount?: number; + depositorName?: string; + }; + messages: Array<{ role: 'user' | 'assistant'; content: string }>; + createdAt: number; + updatedAt: number; +} +``` + +**Step 2: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 3: 커밋** + +```bash +git add src/types.ts +git commit -m "feat: add DomainSession and DepositSession types" +``` + +--- + +## Phase 2: 도메인 에이전트 + +### Task 2.1: domain-agent.ts 기본 구조 생성 + +**Files:** +- Create: `src/agents/domain-agent.ts` + +**Step 1: 세션 CRUD 함수 작성** + +```typescript +/** + * Domain Agent - 도메인 관리 AI 에이전트 + * + * 기능: + * - 대화형 도메인 추천 상담 + * - 도메인 등록 확인 흐름 + * - 네임서버 변경 확인 흐름 + * - 단순 조회는 즉시 응답 (세션 없음) + */ + +import type { Env, DomainSession } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('domain-agent'); + +const SESSION_TTL_MS = 3600 * 1000; // 1 hour + +// Session CRUD +export async function getDomainSession( + db: D1Database, + userId: string +): Promise { + try { + const now = Date.now(); + const result = await db.prepare( + 'SELECT * FROM domain_sessions WHERE user_id = ? AND expires_at > ?' + ).bind(userId, now).first<{ + user_id: string; + status: string; + collected_info: string | null; + target_domain: string | null; + messages: string | null; + created_at: number; + updated_at: number; + }>(); + + if (!result) { + return null; + } + + return { + telegramUserId: result.user_id, + status: result.status as DomainSession['status'], + collectedInfo: result.collected_info ? JSON.parse(result.collected_info) : {}, + targetDomain: result.target_domain || undefined, + messages: result.messages ? JSON.parse(result.messages) : [], + createdAt: result.created_at, + updatedAt: result.updated_at, + }; + } catch (error) { + logger.error('세션 조회 실패', error as Error, { userId }); + return null; + } +} + +export async function saveDomainSession( + db: D1Database, + userId: string, + session: DomainSession +): Promise { + try { + const now = Date.now(); + const expiresAt = now + SESSION_TTL_MS; + + await db.prepare(` + INSERT INTO domain_sessions + (user_id, status, collected_info, target_domain, messages, created_at, updated_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + status = excluded.status, + collected_info = excluded.collected_info, + target_domain = excluded.target_domain, + messages = excluded.messages, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at + `).bind( + userId, + session.status, + JSON.stringify(session.collectedInfo || {}), + session.targetDomain || null, + JSON.stringify(session.messages || []), + session.createdAt || now, + now, + expiresAt + ).run(); + + logger.info('세션 저장 성공', { userId, status: session.status }); + } catch (error) { + logger.error('세션 저장 실패', error as Error, { userId }); + throw error; + } +} + +export async function deleteDomainSession( + db: D1Database, + userId: string +): Promise { + try { + await db.prepare('DELETE FROM domain_sessions WHERE user_id = ?') + .bind(userId) + .run(); + logger.info('세션 삭제 성공', { userId }); + } catch (error) { + logger.error('세션 삭제 실패', error as Error, { userId }); + throw error; + } +} + +export async function cleanupExpiredDomainSessions(db: D1Database): Promise { + try { + const result = await db.prepare( + 'DELETE FROM domain_sessions WHERE expires_at < ?' + ).bind(Date.now()).run(); + + const deleted = result.meta.changes || 0; + if (deleted > 0) { + logger.info('만료 세션 정리', { deleted }); + } + return deleted; + } catch (error) { + logger.error('만료 세션 정리 실패', error as Error); + return 0; + } +} +``` + +**Step 2: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 3: 커밋** + +```bash +git add src/agents/domain-agent.ts +git commit -m "feat: add domain-agent session CRUD functions" +``` + +--- + +### Task 2.2: 도메인 에이전트 AI 호출 함수 작성 + +**Files:** +- Modify: `src/agents/domain-agent.ts` + +**Step 1: AI 도구 정의 및 호출 함수 추가** + +```typescript +// Domain Expert AI Tools +const domainAgentTools = [ + { + type: 'function' as const, + function: { + name: 'check_domain', + description: '도메인 등록 가능 여부와 가격을 확인합니다.', + parameters: { + type: 'object', + properties: { + domain: { + type: 'string', + description: '확인할 도메인 (예: example.com)', + }, + }, + required: ['domain'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'search_suggestions', + description: '키워드 기반으로 도메인을 추천합니다.', + parameters: { + type: 'object', + properties: { + keywords: { + type: 'string', + description: '도메인 추천을 위한 키워드', + }, + }, + required: ['keywords'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'get_whois', + description: '도메인 WHOIS 정보를 조회합니다.', + parameters: { + type: 'object', + properties: { + domain: { + type: 'string', + description: '조회할 도메인', + }, + }, + required: ['domain'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'get_tld_price', + description: 'TLD 가격을 조회합니다.', + parameters: { + type: 'object', + properties: { + tld: { + type: 'string', + description: 'TLD (예: com, io, net)', + }, + }, + required: ['tld'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'list_my_domains', + description: '사용자의 도메인 목록을 조회합니다.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'get_nameservers', + description: '도메인의 네임서버를 조회합니다.', + parameters: { + type: 'object', + properties: { + domain: { + type: 'string', + description: '조회할 도메인', + }, + }, + required: ['domain'], + }, + }, + }, +]; + +// AI 호출 함수 +async function callDomainExpertAI( + env: Env, + session: DomainSession, + userMessage: string +): Promise<{ + action: 'question' | 'suggest' | 'register' | 'set_ns' | 'immediate'; + message: string; + collectedInfo?: DomainSession['collectedInfo']; + targetDomain?: string; + targetNameservers?: string[]; + toolCalls?: Array<{ name: string; args: Record }>; +}> { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY not configured'); + } + + const { getOpenAIUrl } = await import('../utils/api-urls'); + + const conversationHistory = session.messages.map(m => ({ + role: m.role === 'user' ? 'user' as const : 'assistant' as const, + content: m.content, + })); + + const systemPrompt = `당신은 10년 경력의 도메인 컨설턴트입니다. + +## 전문성 +- 브랜딩, SEO, 가격 대비 가치를 고려한 조언 제공 +- 불필요한 프리미엄 도메인 추천 자제 +- 실용적이고 기억하기 쉬운 도메인 추천 + +## 성격 +- 따뜻하고 친근하지만 전문적인 어조 +- 비기술자도 이해하기 쉽게 설명 + +## 작업 분류 + +### 세션 필요 작업 (action으로 반환) +- "도메인 추천" → action="suggest", 키워드/용도 수집 +- "도메인 등록" → action="register", 도메인명 확인 +- "네임서버 변경" → action="set_ns", 변경할 NS 확인 + +### 즉시 응답 작업 (도구 호출 후 action="immediate") +- 가격 조회 → get_tld_price 도구 호출 +- WHOIS 조회 → get_whois 도구 호출 +- 내 도메인 목록 → list_my_domains 도구 호출 +- 네임서버 조회 → get_nameservers 도구 호출 +- 도메인 가용성 확인 → check_domain 도구 호출 + +## 현재 수집된 정보 +${JSON.stringify(session.collectedInfo, null, 2)} + +## 응답 형식 (반드시 JSON만 반환) +{ + "action": "question" | "suggest" | "register" | "set_ns" | "immediate", + "message": "사용자에게 보여줄 메시지", + "collectedInfo": { ... }, // 수집된 정보 업데이트 + "targetDomain": "example.com", // register, set_ns 시 + "targetNameservers": ["ns1.example.com", "ns2.example.com"] // set_ns 시 +}`; + + try { + const response = await fetch(getOpenAIUrl(env), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + ...conversationHistory, + { role: 'user', content: userMessage }, + ], + tools: domainAgentTools, + tool_choice: 'auto', + response_format: { type: 'json_object' }, + max_tokens: 800, + temperature: 0.7, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${error}`); + } + + const data = await response.json() as { + choices: Array<{ + message: { + content: string | null; + tool_calls?: Array<{ + id: string; + function: { name: string; arguments: string }; + }>; + }; + }>; + }; + + const assistantMessage = data.choices[0].message; + + // Tool calls가 있으면 처리 + if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { + const toolCalls = assistantMessage.tool_calls.map(tc => ({ + name: tc.function.name, + args: JSON.parse(tc.function.arguments), + })); + + return { + action: 'immediate', + message: '', + toolCalls, + }; + } + + // JSON 응답 파싱 + const aiResponse = assistantMessage.content || ''; + const parsed = JSON.parse(aiResponse); + + return { + action: parsed.action, + message: parsed.message, + collectedInfo: parsed.collectedInfo, + targetDomain: parsed.targetDomain, + targetNameservers: parsed.targetNameservers, + }; + } catch (error) { + logger.error('Domain Expert AI 호출 실패', error as Error); + throw error; + } +} +``` + +**Step 2: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 3: 커밋** + +```bash +git add src/agents/domain-agent.ts +git commit -m "feat: add domain-agent AI tools and callDomainExpertAI function" +``` + +--- + +### Task 2.3: 도메인 에이전트 도구 실행 함수 이관 + +**Files:** +- Modify: `src/agents/domain-agent.ts` +- Reference: `src/tools/domain-tool.ts` (로직 복사) + +**Step 1: domain-tool.ts에서 핵심 로직 복사** + +기존 `domain-tool.ts`의 `callNamecheapApi`, `executeDomainAction` 함수들을 +`domain-agent.ts`로 이관. 단, 함수명 변경: + +- `executeDomainAction` → `executeDomainTool` + +```typescript +// domain-agent.ts에 추가 + +// Namecheap API 호출 함수 (domain-tool.ts에서 복사) +async function callNamecheapApi( + funcName: string, + funcArgs: Record, + allowedDomains: string[], + env?: Env, + telegramUserId?: string, + db?: D1Database, + userId?: number +): Promise { + // ... domain-tool.ts의 callNamecheapApi 함수 전체 복사 +} + +// 도구 실행 함수 +async function executeDomainTool( + toolName: string, + args: Record, + env: Env, + telegramUserId: string, + db: D1Database +): Promise { + // 사용자 도메인 목록 조회 + const user = await db.prepare( + 'SELECT id FROM users WHERE telegram_id = ?' + ).bind(telegramUserId).first<{ id: number }>(); + + if (!user) { + return '🚫 사용자 정보를 찾을 수 없습니다.'; + } + + const domains = await db.prepare( + 'SELECT domain FROM user_domains WHERE user_id = ? AND verified = 1' + ).bind(user.id).all<{ domain: string }>(); + const userDomains = domains.results?.map(d => d.domain) || []; + + switch (toolName) { + case 'check_domain': { + // domain-tool.ts의 'check' action 로직 + const domain = args.domain as string; + // ... 구현 + } + case 'search_suggestions': { + // domain-tool.ts의 executeSuggestDomains 로직 + // ... 구현 + } + case 'get_whois': { + // domain-tool.ts의 'whois' action 로직 + // ... 구현 + } + case 'get_tld_price': { + // domain-tool.ts의 'price' action 로직 + // ... 구현 + } + case 'list_my_domains': { + // domain-tool.ts의 'list' action 로직 + // ... 구현 + } + case 'get_nameservers': { + // domain-tool.ts의 'get_ns' action 로직 + // ... 구현 + } + default: + return `알 수 없는 도구: ${toolName}`; + } +} +``` + +**Step 2: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 3: 커밋** + +```bash +git add src/agents/domain-agent.ts +git commit -m "feat: add domain tool execution functions to domain-agent" +``` + +--- + +### Task 2.4: 도메인 에이전트 메인 처리 함수 작성 + +**Files:** +- Modify: `src/agents/domain-agent.ts` + +**Step 1: processDomainConsultation 함수 작성** + +```typescript +export async function processDomainConsultation( + userMessage: string, + session: DomainSession, + env: Env +): Promise { + try { + logger.info('도메인 상담 처리 시작', { + userId: session.telegramUserId, + message: userMessage.slice(0, 50), + status: session.status + }); + + // 취소 키워드 처리 + if (/^(취소|다시|처음|리셋)/.test(userMessage.trim())) { + await deleteDomainSession(env.DB, session.telegramUserId); + return '도메인 상담이 취소되었습니다. 다시 시작하려면 "도메인 추천"이라고 말씀해주세요.'; + } + + // 무관한 메시지 감지 + const unrelatedPatterns = /날씨|시간|계산|서버|입금|충전|잔액/; + if (unrelatedPatterns.test(userMessage)) { + return '__PASSTHROUGH__'; + } + + // 세션에 메시지 추가 + session.messages.push({ role: 'user', content: userMessage }); + + // AI 호출 + const aiResult = await callDomainExpertAI(env, session, userMessage); + + // 도구 호출이 있으면 실행 + if (aiResult.action === 'immediate' && aiResult.toolCalls) { + const results: string[] = []; + for (const toolCall of aiResult.toolCalls) { + const result = await executeDomainTool( + toolCall.name, + toolCall.args, + env, + session.telegramUserId, + env.DB + ); + results.push(result); + } + + // 세션 삭제 (즉시 응답은 세션 불필요) + await deleteDomainSession(env.DB, session.telegramUserId); + return results.join('\n\n'); + } + + // 수집된 정보 업데이트 + if (aiResult.collectedInfo) { + session.collectedInfo = { ...session.collectedInfo, ...aiResult.collectedInfo }; + } + if (aiResult.targetDomain) { + session.targetDomain = aiResult.targetDomain; + } + if (aiResult.targetNameservers) { + session.targetNameservers = aiResult.targetNameservers; + } + + // 세션에 AI 응답 추가 + session.messages.push({ role: 'assistant', content: aiResult.message }); + + // 상태 전환 + switch (aiResult.action) { + case 'question': + session.status = 'gathering'; + break; + case 'suggest': + session.status = 'suggesting'; + // TODO: 도메인 추천 실행 + break; + case 'register': + session.status = 'confirming'; + // TODO: 등록 확인 흐름 + break; + case 'set_ns': + session.status = 'setting_ns'; + // TODO: NS 변경 확인 흐름 + break; + } + + await saveDomainSession(env.DB, session.telegramUserId, session); + return aiResult.message; + + } catch (error) { + logger.error('도메인 상담 처리 실패', error as Error); + await deleteDomainSession(env.DB, session.telegramUserId); + return '죄송합니다. 도메인 상담 중 오류가 발생했습니다.\n다시 시도하려면 "도메인 추천"이라고 말씀해주세요.'; + } +} +``` + +**Step 2: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 3: 커밋** + +```bash +git add src/agents/domain-agent.ts +git commit -m "feat: add processDomainConsultation main handler" +``` + +--- + +### Task 2.5: openai-service.ts에 도메인 세션 체크 추가 + +**Files:** +- Modify: `src/openai-service.ts` + +**Step 1: import 추가** + +```typescript +import { getDomainSession, processDomainConsultation } from './agents/domain-agent'; +``` + +**Step 2: generateOpenAIResponse에 도메인 세션 체크 추가** + +서버 세션 체크 전에 도메인 세션 체크 추가: + +```typescript +export async function generateOpenAIResponse( + env: Env, + userMessage: string, + systemPrompt: string, + recentContext: { role: 'user' | 'assistant'; content: string }[], + telegramUserId?: string, + db?: D1Database, + chatIdStr?: string +): Promise { + // Check if domain session is active + if (telegramUserId && env.DB) { + try { + const domainSession = await getDomainSession(env.DB, telegramUserId); + + if (domainSession && domainSession.status !== 'completed') { + logger.info('Active domain session detected', { + userId: telegramUserId, + status: domainSession.status + }); + + const result = await processDomainConsultation(userMessage, domainSession, env); + + if (result !== '__PASSTHROUGH__') { + return result; + } + } + } catch (error) { + logger.error('Domain session check failed', error as Error); + } + } + + // ... 기존 서버 세션 체크 코드 ... +``` + +**Step 3: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 4: 커밋** + +```bash +git add src/openai-service.ts +git commit -m "feat: add domain session check to openai-service" +``` + +--- + +### Task 2.6: domain-tool.ts를 에이전트 시작 트리거로 변경 + +**Files:** +- Modify: `src/tools/domain-tool.ts` + +**Step 1: manage_domain 도구를 세션 시작 트리거로 변경** + +```typescript +// domain-tool.ts 전체 리팩토링 + +import type { Env } from '../types'; +import { createLogger } from '../utils/logger'; +import { getDomainSession, saveDomainSession } from '../agents/domain-agent'; + +const logger = createLogger('domain-tool'); + +export const manageDomainTool = { + type: 'function', + function: { + name: 'manage_domain', + description: '도메인 관련 모든 작업을 처리합니다. 도메인 추천, 등록, 가격 조회, WHOIS, 네임서버 관리 등.', + parameters: { + type: 'object', + properties: { + request: { + type: 'string', + description: '사용자의 도메인 관련 요청 (자연어)', + }, + }, + required: ['request'], + }, + }, +}; + +// suggest_domains 도구는 manage_domain으로 통합, 제거 예정 +export const suggestDomainsTool = manageDomainTool; + +export async function executeManageDomain( + args: { request: string }, + env?: Env, + telegramUserId?: string, + db?: D1Database +): Promise { + if (!env || !telegramUserId || !db) { + return '🚫 도메인 관리 권한이 없습니다.'; + } + + // 세션 시작 + const existingSession = await getDomainSession(db, telegramUserId); + + if (!existingSession) { + // 새 세션 생성 + const newSession = { + telegramUserId, + status: 'gathering' as const, + collectedInfo: {}, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await saveDomainSession(db, telegramUserId, newSession); + + logger.info('도메인 세션 시작', { userId: telegramUserId }); + return '__START_DOMAIN_SESSION__'; // 특수 마커로 세션 시작 알림 + } + + return '__DOMAIN_SESSION_ACTIVE__'; // 이미 세션 활성화 중 +} + +export async function executeSuggestDomains( + args: { keywords: string }, + env?: Env, + telegramUserId?: string, + db?: D1Database +): Promise { + // manage_domain으로 리다이렉트 + return executeManageDomain({ request: `도메인 추천: ${args.keywords}` }, env, telegramUserId, db); +} +``` + +**Step 2: tools/index.ts 수정** + +도구 목록에서 suggest_domains 제거 또는 manage_domain과 통합 + +**Step 3: 빌드 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npx tsc --noEmit` +Expected: 타입 에러 없음 + +**Step 4: 커밋** + +```bash +git add src/tools/domain-tool.ts src/tools/index.ts +git commit -m "refactor: convert domain-tool to session start trigger" +``` + +--- + +## Phase 3: 예치금 에이전트 (Task 3.1 - 3.5) + +Phase 2와 동일한 패턴으로: + +### Task 3.1: deposit-agent.ts를 agents로 이동 및 세션 로직 추가 +### Task 3.2: 예치금 에이전트 AI 호출 함수 작성 +### Task 3.3: 예치금 에이전트 도구 실행 함수 정리 +### Task 3.4: processDepositConsultation 함수 작성 +### Task 3.5: openai-service.ts에 예치금 세션 체크 추가 + +(상세 내용은 Phase 2 패턴과 동일하게 진행) + +--- + +## Phase 4: 정리 및 테스트 + +### Task 4.1: 사용하지 않는 코드 정리 + +**Files:** +- Delete: `src/tools/deposit-tool.ts` (deposit-agent로 통합됨) +- Modify: `src/tools/index.ts` (import 정리) + +### Task 4.2: 로컬 테스트 + +**Step 1: 로컬 서버 실행** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run dev` + +**Step 2: 도메인 에이전트 테스트** + +```bash +# 도메인 추천 테스트 +curl -X POST http://localhost:8787/webhook \ + -H "Content-Type: application/json" \ + -H "X-Telegram-Bot-Api-Secret-Token: test" \ + -d '{"message":{"chat":{"id":123},"from":{"id":123},"text":"도메인 추천해줘"}}' + +# .com 가격 조회 테스트 (즉시 응답) +curl -X POST http://localhost:8787/webhook \ + -H "Content-Type: application/json" \ + -H "X-Telegram-Bot-Api-Secret-Token: test" \ + -d '{"message":{"chat":{"id":123},"from":{"id":123},"text":".com 가격"}}' +``` + +**Step 3: 예치금 에이전트 테스트** + +```bash +# 충전 테스트 (세션 시작) +curl -X POST http://localhost:8787/webhook \ + -H "Content-Type: application/json" \ + -H "X-Telegram-Bot-Api-Secret-Token: test" \ + -d '{"message":{"chat":{"id":123},"from":{"id":123},"text":"충전할게"}}' + +# 잔액 조회 테스트 (즉시 응답) +curl -X POST http://localhost:8787/webhook \ + -H "Content-Type: application/json" \ + -H "X-Telegram-Bot-Api-Secret-Token: test" \ + -d '{"message":{"chat":{"id":123},"from":{"id":123},"text":"잔액"}}' +``` + +### Task 4.3: 문서 업데이트 + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `README.md` + +파일 구조, 에이전트 설명 업데이트 + +### Task 4.4: 최종 커밋 및 배포 + +```bash +git add . +git commit -m "feat: complete domain and deposit agent refactoring" + +# 프로덕션 마이그레이션 (주의!) +wrangler d1 execute telegram-conversations --file=migrations/006_add_agent_sessions.sql + +# 배포 +npm run deploy +``` + +--- + +## 위험 요소 체크리스트 + +- [ ] 마이그레이션 전 D1 백업 +- [ ] 기존 domain-tool.ts 테스트가 통과하는지 확인 +- [ ] 기존 deposit-agent.ts 테스트가 통과하는지 확인 +- [ ] 세션 TTL 만료 후 정상 동작 확인 +- [ ] `__PASSTHROUGH__` 동작 확인 +- [ ] Circuit Breaker 동작 확인 diff --git a/src/agents/domain-agent.ts b/src/agents/domain-agent.ts index 165f7fa..28a662e 100644 --- a/src/agents/domain-agent.ts +++ b/src/agents/domain-agent.ts @@ -593,10 +593,93 @@ export async function processDomainConsultation( userMessage: string, env: Env ): Promise { - // TODO: Implement in Task 8 - logger.info('도메인 상담 처리 요청 (미구현)', { - userId, - message: userMessage.slice(0, 50), - }); - return '__PASSTHROUGH__'; + const startTime = Date.now(); + logger.info('도메인 상담 시작', { userId, message: userMessage.substring(0, 100) }); + + try { + // 1. Check for existing session + let session = await getDomainSession(db, userId); + + // 2. Create new session if none exists and message seems domain-related + // (For first call, we always try to process - AI will return __PASSTHROUGH__ if not relevant) + if (!session) { + session = createDomainSession(userId, 'gathering'); + } + + // 3. Add user message to session + addMessageToSession(session, 'user', userMessage); + + // 4. Call AI to get response and possible tool calls + const aiResult = await callDomainExpertAI(session, userMessage, env); + + // 5. Handle __PASSTHROUGH__ - not domain related + if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) { + logger.info('도메인 상담 패스스루', { userId }); + // Don't save session if passthrough + return '__PASSTHROUGH__'; + } + + // 6. Execute tool calls if any + let toolResults: string[] = []; + if (aiResult.toolCalls && aiResult.toolCalls.length > 0) { + for (const toolCall of aiResult.toolCalls) { + const result = await executeDomainToolCall( + toolCall.name, + toolCall.arguments, + env, + userId, + db + ); + toolResults.push(result); + } + } + + // 7. Build final response + let finalResponse = aiResult.response; + if (toolResults.length > 0) { + // If we have tool results, include them in the response + // The AI response might be a summary, but tool results have the actual data + finalResponse = toolResults.join('\n\n'); + if (aiResult.response && !aiResult.response.includes('__SESSION_END__')) { + finalResponse = aiResult.response + '\n\n' + finalResponse; + } + } + + // 8. Handle __SESSION_END__ - session complete + if (finalResponse.includes('__SESSION_END__')) { + logger.info('도메인 상담 세션 종료', { userId }); + await deleteDomainSession(db, userId); + finalResponse = finalResponse.replace('__SESSION_END__', '').trim(); + return finalResponse; + } + + // 9. Add assistant response to session and save + addMessageToSession(session, 'assistant', finalResponse); + session.updated_at = Date.now(); + await saveDomainSession(db, session); + + logger.info('도메인 상담 완료', { + userId, + duration: Date.now() - startTime, + hasToolCalls: aiResult.toolCalls?.length || 0 + }); + + return finalResponse; + + } catch (error) { + logger.error('도메인 상담 오류', error as Error, { userId }); + return '죄송합니다. 도메인 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } +} + +/** + * 도메인 세션 존재 여부 확인 (라우팅용) + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @returns true if active session exists, false otherwise + */ +export async function hasDomainSession(db: D1Database, userId: string): Promise { + const session = await getDomainSession(db, userId); + return session !== null && !isSessionExpired(session); } diff --git a/src/tools/domain-tool.ts b/src/tools/domain-tool.ts index 73784bd..0e31bfa 100644 --- a/src/tools/domain-tool.ts +++ b/src/tools/domain-tool.ts @@ -470,7 +470,7 @@ async function callNamecheapApi( } // 도메인 작업 직접 실행 (Agent 없이 코드로 처리) -async function executeDomainAction( +export async function executeDomainAction( action: string, args: { domain?: string; nameservers?: string[]; tld?: string }, allowedDomains: string[],