diff --git a/CLAUDE.md b/CLAUDE.md index 6b3ab30..bf5a802 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - 주요 디렉토리: `src/tools/`, `src/routes/`, `src/services/` **사용 가능한 에이전트 타입:** -- `general-purpose`: 범용 작업, 코드 작성/수정 +- `coder`: 20년 이상의 경험을 갖고 있는 시니어 코딩 전문가 (코드 작성/수정 최우선) + - TypeScript/Cloudflare Workers 구현 마스터 + - 프로덕션 수준의 코드 품질 보장 + - 엔터프라이즈급 에러 핸들링 및 타입 안정성 + - 성능 최적화 및 베스트 프랙티스 적용 +- `general-purpose`: 범용 작업 (coder 사용 불가 시 폴백) - `Explore`: 프로젝트 구조 분석 (thorough 레벨) - `Bash`: 빌드/배포/테스트 실행 @@ -36,12 +41,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | 작업 유형 | 조건 | 에이전트 타입 | 이유 | |-----------|------|---------------|------| -| **코드 작성/수정** | 모든 코드 변경 | `general-purpose` | 컨텍스트 절약, 독립 실행 | -| **리팩토링** | 파일 수 무관 | `general-purpose` (병렬) | 일관성, 컨텍스트 분리 | -| **Function Calling 도구** | 추가/수정 | `general-purpose` (병렬) | tools/ + openai-service.ts 동시 처리 | -| **스키마 작업** | D1 마이그레이션 | `general-purpose` | 백업→마이그레이션→검증 전체 위임 | +| **코드 작성/수정** | 모든 코드 변경 | `coder` | TS/Workers 전문, 타입 안정성, 프로덕션 품질 | +| **리팩토링** | 파일 수 무관 | `coder` (병렬) | 일관성, 컨텍스트 분리, TS 최적화 | +| **Function Calling 도구** | 추가/수정 | `coder` (병렬) | tools/ + openai-service.ts 동시 처리 | +| **스키마 작업** | D1 마이그레이션 | `coder` | 백업→마이그레이션→검증 전체 위임 | | **프로젝트 분석** | 구조 파악 | `Explore` (thorough) | 대량 파일 읽기 분리 | -| **코드 리뷰** | 보안/성능 | `Explore` + `general-purpose` | 분석 후 개선 제안 | +| **코드 리뷰** | 보안/성능 | `Explore` + `coder` | 분석 후 개선 제안 | | **빌드/배포** | npm run, wrangler | `Bash` | 긴 로그 출력 분리 | | **테스트** | 로컬 테스트 실행 | `Bash` | 테스트 출력 분리 | @@ -52,8 +57,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - ✅ 메인 세션은 조율/지시만 담당 **병렬 처리 필수:** -- 독립적인 파일 여러 개 → 병렬 general-purpose 에이전트 -- 다른 디렉토리 동시 작업 → 병렬 general-purpose 에이전트 +- 독립적인 파일 여러 개 → 병렬 `coder` 에이전트 +- 다른 디렉토리 동시 작업 → 병렬 `coder` 에이전트 - Function Calling 도구 추가 → tools/{new}.ts + openai-service.ts 병렬 **예시:** @@ -64,9 +69,10 @@ Edit src/tools/weather-tool.ts Read src/openai-service.ts Edit src/openai-service.ts -// ✅ 에이전트 사용 (컨텍스트 절약) -Task (subagent_type: "general-purpose", 2개 병렬) +// ✅ coder 에이전트 사용 (컨텍스트 절약 + 전문성) +Task (subagent_type: "coder", 2개 병렬) → 독립 컨텍스트에서 작업 → 요약만 반환 + → TypeScript 최적화, Workers 패턴 준수 ``` **직접 처리 (최소화):** @@ -176,20 +182,35 @@ curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info ### 에러 핸들링 ```typescript -// 패턴: try-catch + 사용자 친화적 메시지 +// 패턴: try-catch + 사용자 친화적 메시지 + 구조화된 로깅 +import { createLogger } from './utils/logger'; +const logger = createLogger('service-name'); + try { // 작업 } catch (error) { - console.error('[ServiceName] 작업 실패:', error); + logger.error('작업 실패', error as Error, { context: 'data' }); return '죄송합니다. 일시적인 오류가 발생했습니다.'; } ``` ### 로깅 규칙 ```typescript -console.log('[ServiceName] 동작 설명'); // 정상 동작 -console.error('[ServiceName] 에러 설명:', error); // 에러 +// 구조화된 로깅 (Phase 5-3에서 도입) +import { createLogger } from './utils/logger'; +const logger = createLogger('service-name'); + +logger.info('동작 설명', { key: 'value' }); // 정상 동작 +logger.error('에러 설명', error as Error); // 에러 +logger.warn('경고 메시지', { context: 'data' }); // 경고 + +// 성능 측정 +const end = logger.startTimer('작업 완료'); +await doWork(); +end(); // duration 자동 기록 + // wrangler tail로 확인 가능 +// 프로덕션: JSON 형식, 개발: 읽기 쉬운 형식 자동 전환 ``` ### 네이밍 @@ -431,6 +452,27 @@ Telegram Webhook → Security Validation → Command/Message Router | `commands.ts` | 봇 명령어 | `handleCommand()` | | `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` | +**Logging & Monitoring (Phase 5-3):** +| 파일 | 역할 | 주요 기능 | +|------|------|----------| +| `utils/logger.ts` | 구조화된 로깅 | JSON 기반 로그, 환경별 전환 (개발/프로덕션) | +| `utils/metrics.ts` | 성능 메트릭 수집 | API 호출 시간, 에러율, Circuit Breaker 상태 | +| `utils/circuit-breaker.ts` | Circuit Breaker | OpenAI API 보호, 자동 복구 | +| `utils/retry.ts` | 재시도 로직 | 지수 백오프, 15개 API 지원 | + +**Logger 사용 예시:** +```typescript +import { createLogger } from './utils/logger'; +const logger = createLogger('openai'); + +logger.info('AI 응답 생성', { model: 'gpt-4o-mini' }); +logger.error('API 호출 실패', error as Error, { retryCount: 3 }); + +const end = logger.startTimer('처리 완료'); +await process(); +end(); // duration 자동 기록 +``` + **Function Calling Tools (8개):** | 도구 | 함수명 | 외부 API | 트리거 키워드 | |------|--------|----------|---------------| diff --git a/README.md b/README.md index bb72910..4e98d7a 100644 --- a/README.md +++ b/README.md @@ -1,715 +1,79 @@ -# Cloudflare Workers 텔레그램 봇 +# 🤖 Cloudflare Workers 텔레그램 AI 봇 -> Cloudflare Workers + D1 + OpenAI를 활용한 사용자 프로필 기반 텔레그램 봇 +> **Cloudflare Workers + D1 + OpenAI**를 활용한 서버리스 아키텍처 기반의 지능형 텔레그램 봇입니다. +> 사용자별 프로필을 자동으로 생성하고 기억하며, 예치금 관리 및 도메인 등록 등의 복잡한 작업을 수행할 수 있습니다. -**📖 이 문서**: 기능 소개, 배포 가이드, 사용법 (사용자/운영자용) -**🔧 [CLAUDE.md](./CLAUDE.md)**: 기술 상세, 코드 패턴, 트러블슈팅 (개발자용) +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue) +![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-orange) -## 목차 +## 📚 문서 안내 -1. [개요](#개요) -2. [아키텍처](#아키텍처) -3. [Function Calling](#function-calling) -4. [예치금 시스템](#예치금-시스템) -5. [도메인 관리](#도메인-관리) -6. [프로젝트 구조](#프로젝트-구조) -7. [배포 가이드](#배포-가이드) -8. [보안 설정](#보안-설정) -9. [봇 명령어](#봇-명령어) +- **[사용자 가이드 (User Guide)](./docs/USER_GUIDE.md)**: 봇 사용법, 명령어, 예치금/도메인 기능 설명 +- **[시스템 아키텍처 (Architecture)](./docs/ARCHITECTURE.md)**: 기술 구조, 데이터 흐름, 프로필 시스템 상세 +- **[개발자 가이드 (Dev Guide)](./CLAUDE.md)**: 개발 환경 설정, 컨벤션, 트러블슈팅 --- -## 개요 +## ✨ 주요 기능 -### 주요 기능 - -- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원 -- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축 -- **Function Calling (8개)**: 날씨, 검색, 시간, 계산, 문서 조회, 도메인 관리, **도메인 추천**, 예치금 관리 -- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회 -- **동적 도구 로딩**: 메시지 키워드 기반으로 필요한 도구만 선택하여 토큰 절약 -- **도메인 추천**: GPT가 창의적 도메인 생성 → 가용성 자동 확인 → 가격과 함께 제안 -- **예치금 시스템**: 코드 직접 처리, 은행 입금 자동 감지(SMS/AI 파싱) + 사용자 신고 매칭으로 자동 충전 -- **Email Worker**: SMS → 메일 → 자동 파싱(Regex + AI 폴백)으로 입금 알림 처리 -- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억 -- **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공 -- **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환 - -### 기술 스택 - -| 서비스 | 용도 | -|--------|------| -| **Workers** | 서버리스 런타임 | -| **D1** | SQLite 데이터베이스 | -| **OpenAI** | GPT-4o-mini + Function Calling | -| **Context7** | 라이브러리 문서 조회 API | -| **도메인 관리** | 코드 직접 처리 → Namecheap API | -| **도메인 추천** | GPT + Namecheap API (코드 레벨) | -| **예치금 관리** | 코드 직접 처리 → D1 | -| **Namecheap API** | 도메인 조회/가용성/가격 백엔드 | -| **Email Workers** | SMS → 메일 파싱 (입금 알림) | -| **Workers AI** | 폴백용 (Llama 3.1 8B) | +* 🧠 **AI 기반 개인화**: 대화 내용을 분석하여 사용자의 관심사와 맥락을 기억하는 **동적 프로필 시스템** +* 🛠 **Function Calling**: 날씨, 검색, 계산, 시간 등 다양한 도구를 자연어로 호출 +* 💰 **예치금 시스템**: 은행 SMS 자동 파싱(AI 폴백 지원) 및 양방향 매칭을 통한 자동 충전 +* 🌐 **도메인 관리**: 도메인 검색, 추천(AI), 가격 조회, 등록, DNS 관리 통합 +* ⚡ **서버리스**: Cloudflare Workers 위에서 동작하여 별도의 서버 관리 불필요 --- -## 아키텍처 - -### 메시지 처리 흐름 - -``` -[사용자 메시지] - │ - ▼ -┌──────────────────┐ -│ Cloudflare │ -│ Worker │ -└──────────────────┘ - │ - ▼ -┌──────────────────┐ -│ OpenAI API │ ← GPT-4o-mini -│ (Function Call) │ 도구 호출 자동 판단 -└──────────────────┘ - │ - ┌───┴───┬───────┬───────┬───────┬───────┬───────┬───────┐ - ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ -[날씨] [검색] [시간] [계산] [문서] [도메인] [도메인 [예치금] - │ │ │ │ │ │ 추천] │ - │ │ │ │ │ │ │ └── Deposit Agent - │ │ │ │ │ │ └── GPT + Namecheap API - │ │ │ │ │ └── 코드 직접 처리 → Namecheap API - │ │ │ │ └── Context7 - └───┬───┴───────┴───────┴───────┴───────────────────────────┘ - ▼ -┌──────────────────┐ -│ 최종 응답 생성 │ -└──────────────────┘ - │ - ▼ -[D1 저장] → [Telegram 응답] -``` - -### 사용자 프로필 시스템 - -``` -[사용자 메시지] - │ - ▼ -┌──────────────────┐ -│ message_buffer │ ← 최대 19개 (20개 되면 프로필 업데이트) -└──────────────────┘ - │ 20개 도달 - ▼ -┌──────────────────────────────────────────┐ -│ 통합 프로필 분석 │ -│ ┌─────────────────┐ ┌───────────────┐ │ -│ │ 기존 요약 3개 │ │ 새 메시지 20개 │ │ -│ │ [v1][v2][v3] │ │ (사용자 발언) │ │ -│ └────────┬────────┘ └───────┬───────┘ │ -│ └──────────┬────────┘ │ -│ ↓ │ -│ OpenAI 통합 분석 │ -└──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────┐ -│ summaries │ ← 최근 3개만 유지 (슬라이딩 윈도우) -│ [v1] [v2] [v3] │ 새 v4 저장, v1 삭제 -└──────────────────┘ -``` - -**3개 요약 통합 방식:** -- 프로필 업데이트 시 기존 요약 3개 + 새 메시지를 함께 AI에 전달 -- AI가 모든 버전을 종합 분석하여 새로운 통합 프로필 생성 -- 응답 생성 시에도 3개 요약 모두 컨텍스트로 제공 (최신 버전 우선) - -### 프로필 분석 내용 - -| 추출 정보 | 설명 | -|-----------|------| -| **관심사** | 사용자가 자주 언급하는 주제 | -| **목표** | 해결하려는 문제, 달성하려는 것 | -| **맥락** | 직업, 상황, 배경 정보 | -| **선호도** | 좋아하는 것, 싫어하는 것 | -| **질문 패턴** | 무엇에 대해 알고 싶어하는지 | - ---- - -## Function Calling - -OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출합니다. - -### 지원 기능 - -| 기능 | 예시 질문 | API | -|------|-----------|-----| -| **날씨** | "서울 날씨", "도쿄 날씨 알려줘" | wttr.in | -| **검색** | "파이썬이 뭐야", "클라우드플레어란" | Brave Search | -| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 | -| **계산** | "123 * 456", "100의 20%" | 내장 | -| **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 | -| **도메인** | "도메인 목록", "anvil.it.com 네임서버", ".com 가격", "google.com whois" | 코드 직접 처리 + WHOIS API | -| **도메인 추천** | "커피숍 도메인 추천해줘", "스타트업 도메인 아이디어" | GPT + Namecheap | -| **예치금** | "잔액 확인", "충전하고 싶어", "10000원 입금했어" | 코드 직접 처리 | - -### 동작 방식 - -``` -사용자: "서울 날씨 어때?" - │ - ▼ -OpenAI: "get_weather 함수를 호출해야겠다" - │ - ▼ -Worker: wttr.in API 호출 → 날씨 데이터 수신 - │ - ▼ -OpenAI: 날씨 데이터를 자연어로 응답 생성 - │ - ▼ -응답: "🌤 서울 날씨\n온도: 5°C\n습도: 45%..." -``` - -### 동적 도구 로딩 - -메시지 키워드를 분석하여 필요한 도구만 선택적으로 로딩합니다. (토큰 40% 절약) - -| 카테고리 | 도구 | 감지 패턴 | -|----------|------|-----------| -| domain | manage_domain, suggest_domains | 도메인, 네임서버, whois, .com | -| deposit | manage_deposit | 입금, 충전, 잔액, 계좌 | -| weather | get_weather | 날씨, 기온, 비, 눈 | -| search | search_web, lookup_docs | 검색, 찾아, 뭐야, 가격 | -| utility | get_current_time, calculate | (항상 포함) | - -패턴 매칭 없으면 전체 도구 사용 (폴백) - -### AI Gateway - -OpenAI API 호출을 Cloudflare AI Gateway를 통해 프록시하여 지역 제한을 우회합니다. - -``` -Gateway ID: telegram-bot -URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/... -``` - -**대시보드**: Cloudflare Dashboard → AI → AI Gateway → telegram-bot - ---- - -## 예치금 시스템 - -은행 계좌 입금 기반 예치금 충전 시스템입니다. 사용자 신고와 은행 SMS 알림을 양방향으로 자동 매칭합니다. - -### 입금 계좌 - -| 은행 | 계좌번호 | 예금주 | -|------|----------|--------| -| 하나은행 | 427-910018-27104 | 주식회사 아이언클래드 | - -> **Vault 경로**: `secret/companies/ironclad-corp` @ `vault.anvil.it.com` - -### 자동 매칭 흐름 - -``` -[시나리오 1: 사용자가 먼저 신고] - -사용자: "홍길동 50000원 입금했어" - │ - ▼ -┌──────────────────┐ -│ bank_notifications│ ← 기존 은행 알림 확인 -└──────────────────┘ - │ - ├── 매칭 발견 → 즉시 confirmed + 잔액 증가 (대화 중 응답) - │ - └── 매칭 없음 → pending 상태로 대기 (SMS 도착 시 자동 매칭 + 알림) -``` - -``` -[시나리오 2: 은행 SMS가 먼저 도착] - -은행 SMS → 메일 전달 → Cloudflare Email Routing → Worker (email handler) - │ - ▼ -┌──────────────────┐ -│ SMS 파싱 │ ← 입금자명, 금액, 은행 추출 (AI 폴백 지원) -│ (하나/KB/신한) │ -└──────────────────┘ - │ - ▼ -┌──────────────────┐ -│ deposit_transactions│ ← 대기중 입금 확인 -└──────────────────┘ - │ - ├── 매칭 발견 → confirmed + 잔액 증가 + 사용자에게 Telegram 알림 🎉 - │ + 관리자에게 Telegram 알림 - │ - └── 매칭 없음 → bank_notifications에 저장 + 관리자에게 알림 (대기) -``` - -### 지원 기능 - -| 기능 | 설명 | 권한 | -|------|------|------| -| `잔액 조회` | 현재 예치금 잔액 확인 | 모든 사용자 | -| `계좌 안내` | 입금 계좌 정보 표시 | 모든 사용자 | -| `입금 신고` | 입금자명 + 금액으로 충전 요청 | 모든 사용자 | -| `거래 내역` | 최근 거래 내역 조회 | 모든 사용자 | -| `입금 취소` | 대기중 입금 취소 (번호 없으면 최근 pending 자동 선택) | 모든 사용자 | -| `대기 목록` | 미처리 입금 목록 조회 | 관리자 전용 | -| `수동 확인` | 입금 수동 확정 처리 | 관리자 전용 | -| `입금 거절` | 입금 요청 거절 | 관리자 전용 | - -### 사용법 예시 - -``` -# 잔액 확인 -"잔액" → "현재 잔액: 10,000원" - -# 계좌 안내 -"입금할게요" → 계좌 정보 안내 - -# 입금 신고 (자연어 금액 인식 지원) -"홍길동 50000원 입금했어" → 즉시 처리 -"홍길동 만원 입금" → 10,000원으로 인식 -"홍길동 5천원" → 5,000원으로 인식 - -# 거래 내역 -"거래 내역" → "#5: 입금 10원 ✓ (01/17)" - -# 간편 취소 -"취소해줘" → 가장 최근 pending 자동 취소 -``` - -**특징:** -- 🔍 **AI 파싱**: 정형화되지 않은 은행 문자도 AI가 자동 분석하여 처리 -- 🔢 **자연어 금액**: "만원", "5천원", "삼만오천원" 등 자동 변환 -- ⚡ **즉시 실행**: 입금자명+금액 있으면 확인 없이 바로 처리 -- 📋 **동시 요청**: 기존 pending 있어도 새 입금 신고 가능 -- 🗑️ **간편 취소**: 거래번호 없이 "취소해줘"만으로 최근 pending 취소 - -### 자동 알림 시스템 - -자동 매칭 성공 시 **사용자와 관리자 모두에게 Telegram 알림**이 전송됩니다. - -| 이벤트 | 사용자 알림 | 관리자 알림 | -|--------|-------------|-------------| -| 자동 매칭 성공 | ✅ 입금액 + 현재 잔액 | ✅ 입금 정보 + 매칭 완료 | -| 매칭 대기 (SMS만) | - | ⏳ 입금 정보 + 대기 상태 | - -**사용자가 받는 메시지 예시:** -``` -✅ 입금 확인 완료! - -입금액: 7원 -현재 잔액: 7원 - -감사합니다! 🎉 -``` - -### Cloudflare Email Routing - -SMS를 메일로 전달받아 Worker에서 직접 처리합니다. - -**흐름:** -``` -은행 SMS → 메일 전달 → Cloudflare Email Routing → Worker (email handler) - ↓ - SMS 파싱 → DB 저장 → 자동 매칭 - ↓ - 매칭 성공 → 사용자/관리자 Telegram 알림 -``` - -**설정 방법:** -1. Cloudflare Dashboard → Email → Email Routing → Routes -2. 수신 주소 설정 (예: `deposit@your-domain.com`) -3. Worker로 라우팅: `telegram-summary-bot` - -**지원 은행 SMS 패턴:** -- 하나은행 (Web발신): `[Web발신] 하나,01/16, 23:30 427******27104 입금5원 황병하` -- 하나은행 (기존): `[하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원` -- KB국민: `[KB] 입금 50,000원 01/16 14:30 홍길동` -- 신한: `[신한] 01/16 입금 50,000원 홍길동` -- **AI 자동 인식**: 패턴 미일치 시 AI가 내용 분석하여 자동 추출 - ---- - -## 도메인 관리 - -코드 직접 처리 방식의 도메인 관리 기능입니다. 메인 AI가 action 파라미터로 작업을 지정하고, 코드에서 Namecheap API를 호출합니다. - -### 지원 기능 - -| 기능 | 설명 | 권한 | -|------|------|------| -| `도메인 목록` | 내 도메인 목록 조회 | 소유자 | -| `도메인 정보` | 도메인 상세 정보 (내 도메인 아니면 WHOIS 자동 조회) | 누구나 | -| `네임서버 조회` | 현재 네임서버 확인 | 누구나 | -| `네임서버 변경` | 네임서버 설정 변경 | 소유자 | -| `가격 조회` | 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 조회 - -자체 WHOIS API 서버(Vercel)를 통해 TCP 43 포트로 직접 쿼리 - -``` -사용자: "google.com whois" 또는 "google.com 정보" -봇: 등록일, 만료일, 네임서버, 등록기관 정보 표시 -``` - -- **WHOIS API**: `https://whois-api-eight.vercel.app` -- **지원 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개 아이디어 생성 후 일괄 확인 -- 💰 **가격 표시**: 등록 가능한 도메인만 원화 가격 안내 -- 💎 **프리미엄 분류**: 일반/프리미엄 도메인 구분 표시 - ---- - -## 프로젝트 구조 - -``` -telegram-bot-workers/ -├── src/ -│ ├── index.ts # 메인 Worker -│ ├── types.ts # 타입 정의 -│ ├── security.ts # Webhook 보안 검증 -│ ├── telegram.ts # Telegram API 유틸 -│ ├── summary-service.ts # 프로필 분석 서비스 -│ ├── openai-service.ts # OpenAI + Function Calling -│ ├── deposit-agent.ts # 예치금 에이전트 (Assistants API) -│ ├── n8n-service.ts # n8n 연동 (선택) -│ └── commands.ts # 봇 명령어 핸들러 -├── src/services/ -│ ├── bank-sms-parser.ts # SMS 파싱 로직 (Regex + AI) -│ ├── user-service.ts # 사용자 관리 -│ └── conversation-service.ts # 대화 로직 -├── schema.sql # D1 스키마 -├── wrangler.toml # Wrangler 설정 -├── n8n-workflow-example.json # n8n 워크플로우 예시 -├── package.json -├── tsconfig.json -└── README.md -``` - ---- - -## 배포 가이드 - -### 1. 프로젝트 설정 +## 🚀 빠른 시작 (배포 가이드) +### 1. 환경 설정 ```bash -cd telegram-bot-workers +# 의존성 설치 npm install + +# Wrangler 로그인 +npx wrangler login ``` -### 2. D1 데이터베이스 - -현재 설정: - -```toml -[[d1_databases]] -binding = "DB" -database_name = "telegram-conversations" -database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" -``` - -스키마: -- `users` - 사용자 정보 -- `message_buffer` - 메시지 임시 저장 -- `summaries` - 프로필 저장 -- `user_deposits` - 예치금 계정 -- `deposit_transactions` - 예치금 거래 내역 -- `bank_notifications` - 은행 입금 알림 (SMS 파싱) - -### 3. Secrets 설정 - +### 2. 데이터베이스 생성 ```bash -# Bot Token (BotFather에서 발급) -wrangler secret put BOT_TOKEN - -# Webhook Secret -wrangler secret put WEBHOOK_SECRET - -# OpenAI API Key (필수) -wrangler secret put OPENAI_API_KEY - -# Brave Search API Key -wrangler secret put BRAVE_API_KEY - -# Deposit API Secret (namecheap-api 연동용) -wrangler secret put DEPOSIT_API_SECRET - -# namecheap-api 래퍼 인증 키 (도메인 추천용) -wrangler secret put NAMECHEAP_API_KEY +npx wrangler d1 create telegram-conversations +npx wrangler d1 execute telegram-conversations --file=schema.sql ``` +*생성된 `database_id`를 `wrangler.toml`에 반영해야 합니다.* -### Vault 연동 (선택) - -API 키는 HashiCorp Vault에서 중앙 관리됩니다. - +### 3. 비밀키 설정 (Secrets) ```bash -# Vault에서 OpenAI API 키 조회 -vault kv get secret/openai - -# 저장된 정보 -# - api_key: OpenAI API 키 -# - email: kappa.inouter@gmail.com (계정 관리용) - -# Vault에서 키 가져와서 Worker에 설정 -OPENAI_KEY=$(vault kv get -field=api_key secret/openai) -echo $OPENAI_KEY | wrangler secret put OPENAI_API_KEY +npx wrangler secret put BOT_TOKEN # Telegram Bot Token +npx wrangler secret put OPENAI_API_KEY # OpenAI API Key +npx wrangler secret put WEBHOOK_SECRET # Webhook 검증용 Secret ``` -> **참고**: Vault 서버: `https://vault.anvil.it.com` - -### 4. 배포 - +### 4. 배포 및 웹훅 연결 ```bash -wrangler deploy -``` +# 배포 +npx wrangler deploy -### 5. Webhook 설정 - -```bash -curl https://telegram-summary-bot.kappa-d8e.workers.dev/setup-webhook -curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info -``` - -### 6. CLI 테스트 (선택) - -```bash -# .env 파일 생성 (최초 1회) -echo 'WEBHOOK_SECRET=...' > .env # Vault: secret/telegram-bot - -# 대화형 모드 -npm run chat - -# 단일 메시지 모드 -npm run chat "안녕" +# 웹훅 설정 (배포된 URL 사용) +curl https:///setup-webhook ``` --- -## 보안 설정 +## 🛠 기술 스택 -### Webhook 보안 검증 - -| 검증 항목 | 설명 | -|-----------|------| -| **Secret Token** | `X-Telegram-Bot-Api-Secret-Token` 헤더 검증 | -| **Timing-safe 비교** | 타이밍 공격 방지 | -| **Timestamp** | 60초 이내 메시지만 처리 | -| **Rate Limiting** | 30req/분/사용자 | - -### API 키 관리 - -| 키 | 저장 위치 | 비고 | -|----|-----------|------| -| `BOT_TOKEN` | Wrangler Secret | BotFather 발급 | -| `WEBHOOK_SECRET` | Wrangler Secret | 자동 생성 | -| `OPENAI_API_KEY` | Wrangler Secret + Vault | kappa.inouter@gmail.com | - -**Vault 경로**: `secret/openai` @ `vault.anvil.it.com` +| 분류 | 기술 | 비고 | +|------|------|------| +| **Runtime** | Cloudflare Workers | Serverless | +| **DB** | Cloudflare D1 | SQLite | +| **Cache** | Cloudflare KV | Rate Limiting | +| **AI** | OpenAI GPT-4o-mini | Logic & Tools | +| **Fallback** | Workers AI (Llama 3) | Backup AI | --- -## 봇 명령어 +## 🤝 기여 및 문의 -### 사용자 명령어 - -| 명령어 | 설명 | -|--------|------| -| `/start` | 봇 시작, 기능 소개 | -| `/help` | 도움말 | -| `/profile` | 내 프로필 보기 | -| `/reset` | 대화 초기화 (확인 필요) | -| `/reset-confirm` | 초기화 확인 (실제 삭제) | - -### 개발자 명령어 (숨김) - -| 명령어 | 설명 | -|--------|------| -| `/context` | 현재 컨텍스트 상태 (버퍼 수, 프로필 버전) | -| `/stats` | 대화 통계 | -| `/debug` | 디버그 정보 | - ---- - -## 설정값 - -`wrangler.toml`: - -```toml -name = "telegram-summary-bot" -main = "src/index.ts" -compatibility_date = "2024-01-01" - -[ai] -binding = "AI" - -[vars] -SUMMARY_THRESHOLD = "20" -MAX_SUMMARIES_PER_USER = "3" -N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" -DOMAIN_OWNER_ID = "821596605" -DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E" -DEPOSIT_ADMIN_ID = "821596605" - -[[d1_databases]] -binding = "DB" -database_name = "telegram-conversations" -database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" -``` - ---- - -## 비용 예측 - -### 월간 예상 비용 - -| 사용량 | D1 | OpenAI | Workers | 총 | -|--------|-----|--------|---------|-----| -| 1만 메시지 | $0 | ~$0.20 | $0 | **~$0.20** | -| 10만 메시지 | $0 | ~$2.00 | $0 | **~$2.00** | -| 100만 메시지 | $0 | ~$20.00 | ~$0.30 | **~$20.30** | - -> GPT-4o-mini: 입력 $0.15/1M, 출력 $0.60/1M 토큰 - ---- - -## API 엔드포인트 - -| 경로 | 메서드 | 설명 | -|------|--------|------| -| `/` | GET | 서비스 정보 | -| `/health` | GET | 헬스 체크 | -| `/webhook-info` | GET | Webhook 상태 | -| `/setup-webhook` | GET | Webhook 설정 | -| `/webhook` | POST | Telegram Webhook | -| `/api/bank-notification` | POST | 입금 알림 API (레거시, Email Routing으로 대체) | -| `/api/deposit/balance` | GET | 예치금 잔액 조회 (namecheap-api용) | -| `/api/deposit/deduct` | POST | 예치금 차감 (namecheap-api용) | - ---- - -## 참고 - -- [OpenAI API](https://platform.openai.com/docs) -- [Cloudflare D1](https://developers.cloudflare.com/d1/) -- [Cloudflare Workers](https://developers.cloudflare.com/workers/) -- [Telegram Bot API](https://core.telegram.org/bots/api) -- [Context7 API](https://context7.com/docs/api-guide) - ---- - -## 소스 코드 - -**Gitea**: https://gitea.anvil.it.com/kaffa/telegram-bot-workers - -``` \ No newline at end of file +버그 신고나 기능 제안은 Issue를 등록해주세요. +소스 코드는 **[Gitea](https://gitea.anvil.it.com/kaffa/telegram-bot-workers)**에서 관리됩니다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3836be1 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,118 @@ +# 🏗 시스템 아키텍처 + +이 문서는 프로젝트의 기술적 구조, 데이터 흐름, 핵심 로직을 설명합니다. + +## 📋 목차 +1. [전체 아키텍처](#전체-아키텍처) +2. [메시지 처리 흐름](#메시지-처리-흐름) +3. [사용자 프로필 시스템](#사용자-프로필-시스템) +4. [예치금 시스템 로직](#예치금-시스템-로직) +5. [프로젝트 구조](#프로젝트-구조) +6. [보안](#보안) + +--- + +## 전체 아키텍처 + +Cloudflare 에코시스템을 기반으로 한 서버리스 구조입니다. + +| 컴포넌트 | 기술 스택 | 역할 | +|----------|-----------|------| +| **Runtime** | Cloudflare Workers | 메인 로직 실행, 웹훅 처리 | +| **Database** | Cloudflare D1 (SQLite) | 사용자, 대화 요약, 예치금 데이터 저장 | +| **Cache/RateLimit** | Cloudflare KV | API 요청 제한, 단기 데이터 캐싱 | +| **AI** | OpenAI API (GPT-4o-mini) | 자연어 처리, 도구 호출(Function Calling) | +| **Email** | Cloudflare Email Workers | 은행 SMS(이메일) 수신 및 파싱 | + +--- + +## 메시지 처리 흐름 + +```mermaid +graph TD + User[사용자] -->|Message| Telegram + Telegram -->|Webhook| Worker[Cloudflare Worker] + Worker -->|1. Validate| Security[Security Check] + Worker -->|2. Buffer| D1[Message Buffer] + Worker -->|3. AI Request| OpenAI[OpenAI API] + OpenAI -->|Function Call?| Tools[Tool Execution] + Tools -->|Result| OpenAI + OpenAI -->|Response| Worker + Worker -->|Reply| Telegram + Worker -->|4. Profile Update?| SummaryService +``` + +1. **검증**: `X-Telegram-Bot-Api-Secret-Token`을 통한 요청 인증 +2. **버퍼링**: 사용자 메시지를 D1 `message_buffer` 테이블에 임시 저장 +3. **AI 처리**: 컨텍스트와 함께 OpenAI 호출. 필요 시 도구(날씨, 도메인 등) 실행 +4. **요약**: 메시지가 일정 수(예: 20개) 쌓이면 백그라운드에서 프로필 요약 실행 + +--- + +## 사용자 프로필 시스템 (Rolling Summary) + +토큰 비용을 절약하면서 무한한 대화 맥락을 유지하기 위해 **슬라이딩 윈도우** 방식을 사용합니다. + +``` +[메시지 버퍼] (0~19개) + │ + ▼ 20개 도달 시 +[통합 분석 실행] + │ ← 기존 요약본 3개 + 새 메시지 20개 + ▼ +[새로운 요약 생성] + │ + ▼ +[Summaries 테이블] (최신 3개만 유지) +[v4 (New)] [v3] [v2] ... (v1 삭제) +``` + +이 방식은 단순히 대화를 요약하는 것을 넘어, 사용자의 **관심사, 말투, 주요 정보**를 추출하여 프로필화합니다. + +--- + +## 예치금 시스템 로직 + +사용자 신고와 은행 알림(SMS)을 양방향으로 매칭하여 누락 없는 입금 처리를 보장합니다. + +### 데이터 모델 +- **`bank_notifications`**: 은행 SMS 파싱 데이터 (Raw Data) +- **`deposit_transactions`**: 사용자 입금 요청 (Pending/Confirmed) +- **`user_deposits`**: 사용자별 잔액 + +### 매칭 프로세스 +1. **사용자 신고 시**: `bank_notifications`에서 동일 금액/입금자명의 미처리 건 검색 → 있으면 즉시 승인 +2. **SMS 수신 시**: `deposit_transactions`에서 `pending` 상태의 요청 검색 → 있으면 즉시 승인 +3. **매칭 실패 시**: 각각 대기 상태로 저장되어, 추후 상대방 데이터가 들어오면 매칭됨 + +--- + +## 프로젝트 구조 + +``` +src/ +├── index.ts # 엔트리포인트 (Router) +├── security.ts # 보안 (Webhook 검증, Rate Limit) +├── commands.ts # 명령어 핸들러 (/start, /reset 등) +├── routes/ # 라우트 핸들러 (Webhook, API) +├── services/ # 비즈니스 로직 +│ ├── conversation-service.ts # 대화 및 AI 처리 +│ ├── user-service.ts # 사용자 관리 +│ ├── summary-service.ts # 프로필 요약 +│ ├── bank-sms-parser.ts # SMS 파싱 (Regex + AI) +│ └── ... +├── tools/ # Function Calling 도구들 +│ ├── domain-tool.ts +│ ├── deposit-tool.ts +│ └── ... +└── utils/ # 유틸리티 (Logger, Retry 등) +``` + +--- + +## 보안 + +1. **Webhook Secret**: 타이밍 공격 방지 비교(Timing-safe comparison) 적용 +2. **Rate Limiting**: KV를 사용하여 사용자별 분당 요청 수 제한 +3. **API Key 관리**: 모든 키는 `wrangler secret` 또는 Vault를 통해 관리되며 코드에 노출되지 않음 +4. **SQL Injection 방지**: D1의 PreparedStatement (`bind`) 사용 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..b6eda25 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,105 @@ +# 🤖 텔레그램 봇 사용자 가이드 + +이 문서는 봇의 주요 기능과 사용법, 명령어에 대해 설명합니다. + +## 📋 목차 +1. [기본 사용법](#기본-사용법) +2. [기능 상세](#기능-상세) +3. [예치금 시스템](#예치금-시스템) +4. [도메인 관리](#도메인-관리) + +--- + +## 기본 사용법 + +### 봇 명령어 +| 명령어 | 설명 | +|--------|------| +| `/start` | 봇 시작 및 기능 소개 | +| `/help` | 도움말 확인 | +| `/profile` | 내 프로필(AI가 분석한 정보) 보기 | +| `/reset` | 대화 내용 초기화 (새로운 주제로 시작하고 싶을 때) | + +### AI와의 대화 +이 봇은 GPT-4o-mini를 기반으로 동작하며, 대화 맥락을 기억합니다. +- 궁금한 점을 자연스럽게 물어보세요. +- 날씨, 시간, 계산, 검색 등의 기능은 대화 중에 자동으로 실행됩니다. + +--- + +## 기능 상세 + +봇은 대화 내용을 분석하여 다음과 같은 도구를 자동으로 호출합니다. + +| 기능 | 예시 질문 | +|------|-----------| +| **날씨** | "오늘 서울 날씨 어때?", "비 오나?" | +| **검색** | "최신 아이폰 가격 검색해줘", "파이썬이 뭐야?" | +| **시간** | "지금 뉴욕 몇 시야?" | +| **계산** | "123 * 456은?", "5만원의 10%는?" | +| **문서** | "React hooks 사용법 알려줘" (개발자용) | + +--- + +## 예치금 시스템 + +도메인 등록 등에 사용되는 예치금을 충전하고 관리합니다. + +### 1. 입금 계좌 정보 +> **하나은행 427-910018-27104 (주식회사 아이언클래드)** + +"입금 계좌 알려줘" 또는 "충전할래"라고 말하면 언제든 확인할 수 있습니다. + +### 2. 입금 및 충전 방법 +두 가지 방법으로 충전이 가능합니다. + +**방법 A: 입금 후 봇에게 말하기 (가장 빠름)** +입금자명과 금액을 봇에게 말하면 즉시 처리됩니다. +``` +사용자: "홍길동 5만원 입금했어" +봇: "✅ 입금 확인 완료! 현재 잔액: 50,000원" +``` + +**방법 B: 은행 SMS 자동 인식** +은행 입금 문자가 수신되면(관리자), 자동으로 매칭되어 알림이 옵니다. +별도로 봇에게 말하지 않아도 됩니다. + +### 3. 기타 기능 +- **잔액 조회**: "잔액", "내 돈 얼마 있어?" +- **거래 내역**: "거래 내역 보여줘" +- **취소**: 잘못 말했을 경우 "취소해줘"라고 하면 최근 대기 건이 취소됩니다. + +--- + +## 도메인 관리 + +도메인을 검색하고, 등록하고, 관리할 수 있습니다. + +### 1. 도메인 검색 및 추천 +원하는 도메인이 있는지 확인하거나, 아이디어를 얻으세요. + +- **가용성 확인**: "example.com 등록 가능한가요?" +- **가격 조회**: ".com 가격 얼마야?", "가장 싼 도메인 보여줘" +- **도메인 추천**: "커피숍 도메인 추천해줘" (AI가 창의적인 이름을 제안합니다) + +### 2. 도메인 정보 조회 (WHOIS) +도메인의 상세 정보를 조회합니다. 내 도메인이 아니어도 조회 가능합니다. + +- "naver.com 정보" +- "google.com whois" + +### 3. 도메인 등록 +**주의:** 등록 시 예치금이 차감되며, 등록 후에는 취소가 불가능합니다. + +``` +사용자: "my-startup.com 등록해줘" +봇: (가격과 잔액 확인 후 등록 버튼 표시) +사용자: [등록하기] 버튼 클릭 +봇: "✅ 등록 완료!" +``` + +### 4. 내 도메인 관리 +등록한 도메인을 관리합니다. + +- **목록**: "내 도메인 목록" +- **네임서버 변경**: "example.com 네임서버를 ns1.example.com으로 바꿔줘" diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts index 52b100f..47c8aa2 100644 --- a/src/deposit-agent.ts +++ b/src/deposit-agent.ts @@ -12,6 +12,10 @@ * - [관리자] 대기 목록, 입금 확인/거절 */ +import { createLogger } from './utils/logger'; + +const logger = createLogger('deposit-agent'); + export interface DepositContext { userId: number; telegramUserId: string; @@ -378,7 +382,7 @@ ${query}`; for (const toolCall of toolCalls) { const funcName = toolCall.function.name; const funcArgs = JSON.parse(toolCall.function.arguments); - console.log(`[DepositAgent] Function call: ${funcName}`, funcArgs); + logger.info(`Function call: ${funcName}`, funcArgs); const result = await executeDepositFunction(funcName, funcArgs, context); toolOutputs.push({ @@ -431,7 +435,7 @@ ${query}`; return '예치금 에이전트 응답 없음'; } catch (error) { - console.error('[DepositAgent] Error:', error); + logger.error('Error', error as Error); return `예치금 에이전트 오류: ${String(error)}`; } } diff --git a/src/openai-service.ts b/src/openai-service.ts index 3f6680d..6cad8a9 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -2,6 +2,10 @@ import type { Env } from './types'; import { tools, selectToolsForMessage, executeTool } from './tools'; import { retryWithBackoff, RetryError } from './utils/retry'; import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker'; +import { createLogger } from './utils/logger'; +import { metrics } from './utils/metrics'; + +const logger = createLogger('openai'); // Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회) const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions'; @@ -42,36 +46,42 @@ async function callOpenAI( messages: OpenAIMessage[], selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용 ): Promise { - return await retryWithBackoff( - async () => { - const response = await fetch(OPENAI_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages, - tools: selectedTools?.length ? selectedTools : undefined, - tool_choice: selectedTools?.length ? 'auto' : undefined, - max_tokens: 1000, - }), - }); + const timer = metrics.startTimer('api_call_duration', { service: 'openai' }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${response.status} - ${error}`); + try { + return await retryWithBackoff( + async () => { + const response = await fetch(OPENAI_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages, + tools: selectedTools?.length ? selectedTools : undefined, + tool_choice: selectedTools?.length ? 'auto' : undefined, + max_tokens: 1000, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${error}`); + } + + return response.json(); + }, + { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, } - - return response.json(); - }, - { - maxRetries: 3, - initialDelayMs: 1000, - maxDelayMs: 10000, - } - ); + ); + } finally { + timer(); // duration 자동 기록 (성공/실패 관계없이) + } } // 메인 응답 생성 함수 @@ -108,8 +118,10 @@ export async function generateOpenAIResponse( let response = await callOpenAI(apiKey, 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)); + logger.info('tool_calls', { + calls: assistantMessage.tool_calls ? assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments })) : 'none' + }); + logger.info('content', { preview: assistantMessage.content?.slice(0, 100) }); // Function Calling 처리 (최대 3회 반복) let iterations = 0; @@ -154,17 +166,17 @@ export async function generateOpenAIResponse( } catch (error) { // 에러 처리 if (error instanceof CircuitBreakerError) { - console.error('[OpenAI] Circuit breaker open:', error.message); + logger.error('Circuit breaker open', error as Error); return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.'; } if (error instanceof RetryError) { - console.error('[OpenAI] All retry attempts failed:', error.message); + logger.error('All retry attempts failed', error as Error); return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'; } // 기타 에러 - console.error('[OpenAI] Unexpected error:', error); + logger.error('Unexpected error', error as Error); return '죄송합니다. 예상치 못한 오류가 발생했습니다.'; } } @@ -194,17 +206,17 @@ export async function generateProfileWithOpenAI( } catch (error) { // 에러 처리 if (error instanceof CircuitBreakerError) { - console.error('[OpenAI Profile] Circuit breaker open:', error.message); + logger.error('Profile - Circuit breaker open', error as Error); return '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.'; } if (error instanceof RetryError) { - console.error('[OpenAI Profile] All retry attempts failed:', error.message); + logger.error('Profile - All retry attempts failed', error as Error); return '프로필 생성 실패: 재시도 횟수 초과'; } // 기타 에러 - console.error('[OpenAI Profile] Unexpected error:', error); + logger.error('Profile - Unexpected error', error as Error); return '프로필 생성 실패: 예상치 못한 오류'; } } diff --git a/src/services/conversation-service.ts b/src/services/conversation-service.ts index d3ff98c..d74eb6c 100644 --- a/src/services/conversation-service.ts +++ b/src/services/conversation-service.ts @@ -4,7 +4,7 @@ import { processAndSummarize, generateAIResponse, } from '../summary-service'; -import { sendChatAction, sendMessage } from '../telegram'; +import { sendChatAction } from '../telegram'; export interface ConversationResult { responseText: string; @@ -26,7 +26,7 @@ export class ConversationService { telegramUserId: string ): Promise { // 1. 타이핑 액션 전송 (비동기로 실행, 기다리지 않음) - sendChatAction(this.env.BOT_TOKEN, chatId, 'typing').catch(console.error); + sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(console.error); // 2. 사용자 메시지 버퍼에 추가 await addToBuffer(this.env.DB, userId, chatId, 'user', text); diff --git a/src/services/notification.ts b/src/services/notification.ts index 1e675c5..a9d274e 100644 --- a/src/services/notification.ts +++ b/src/services/notification.ts @@ -1,4 +1,7 @@ import { Env } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('notification'); /** * 알림 유형별 메시지 템플릿 @@ -143,14 +146,14 @@ export async function notifyAdmin( try { // 관리자 ID 확인 if (!options.adminId) { - console.log('[Notification] 관리자 ID가 설정되지 않아 알림을 건너뜁니다.'); + logger.info('관리자 ID가 설정되지 않아 알림을 건너뜁니다.'); return; } // Rate Limiting 체크 const canSend = await checkRateLimit(type, details.service, options.env.RATE_LIMIT_KV); if (!canSend) { - console.log(`[Notification] Rate limit: ${type} (${details.service}) - 1시간 이내 알림 전송됨`); + logger.info(`Rate limit: ${type} (${details.service}) - 1시간 이내 알림 전송됨`); return; } @@ -162,12 +165,12 @@ export async function notifyAdmin( const success = await options.telegram.sendMessage(adminChatId, message); if (success) { - console.log(`[Notification] 관리자 알림 전송 성공: ${type} (${details.service})`); + logger.info(`관리자 알림 전송 성공: ${type} (${details.service})`); } else { - console.error(`[Notification] 관리자 알림 전송 실패: ${type} (${details.service})`); + logger.error(`관리자 알림 전송 실패: ${type} (${details.service})`, new Error('Telegram send failed')); } } catch (error) { // 알림 전송 실패는 로그만 기록하고 무시 - console.error('[Notification] 알림 전송 중 오류 발생:', error); + logger.error('알림 전송 중 오류 발생', error as Error); } } diff --git a/src/services/user-service.ts b/src/services/user-service.ts index 5069b03..287cfc0 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -1,5 +1,3 @@ -import { Env } from '../types'; - export class UserService { constructor(private db: D1Database) {} diff --git a/src/tools/deposit-tool.ts b/src/tools/deposit-tool.ts index 9967946..2c42360 100644 --- a/src/tools/deposit-tool.ts +++ b/src/tools/deposit-tool.ts @@ -1,5 +1,8 @@ import { executeDepositFunction, type DepositContext } from '../deposit-agent'; import type { Env } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('deposit-tool'); export const manageDepositTool = { type: 'function', @@ -123,7 +126,7 @@ export async function executeManageDeposit( db?: D1Database ): Promise { const { action, depositor_name, amount, transaction_id, limit } = args; - console.log('[manage_deposit] 시작:', { action, depositor_name, amount, telegramUserId }); + logger.info('시작', { action, depositor_name, amount, telegramUserId }); if (!telegramUserId || !db) { return '🚫 예치금 기능을 사용할 수 없습니다.'; @@ -170,14 +173,14 @@ export async function executeManageDeposit( if (transaction_id) funcArgs.transaction_id = Number(transaction_id); if (limit) funcArgs.limit = Number(limit); - console.log('[manage_deposit] executeDepositFunction 호출:', funcName, funcArgs); + logger.info('executeDepositFunction 호출', { funcName, funcArgs }); const result = await executeDepositFunction(funcName, funcArgs, context); - console.log('[manage_deposit] 결과:', JSON.stringify(result).slice(0, 200)); + logger.info('결과', { result: JSON.stringify(result).slice(0, 200) }); // 결과 포맷팅 (고정 형식) return formatDepositResult(action, result); } catch (error) { - console.error('[manage_deposit] 오류:', error); + logger.error('오류', error as Error); return `🚫 예치금 처리 오류: ${String(error)}`; } } diff --git a/src/tools/domain-tool.ts b/src/tools/domain-tool.ts index 4286c81..04d5640 100644 --- a/src/tools/domain-tool.ts +++ b/src/tools/domain-tool.ts @@ -1,5 +1,8 @@ import type { Env } from '../types'; import { retryWithBackoff, RetryError } from '../utils/retry'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('domain-tool'); // Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회) const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions'; @@ -21,13 +24,13 @@ async function getCachedTLDPrice( const key = `tld_price:${tld}`; const cached = await kv.get(key, 'json'); if (cached) { - console.log(`[TLDCache] HIT: ${tld}`); + logger.info('TLDCache HIT', { tld }); return cached as CachedTLDPrice; } - console.log(`[TLDCache] MISS: ${tld}`); + logger.info('TLDCache MISS', { tld }); return null; } catch (error) { - console.error('[TLDCache] KV 조회 오류:', error); + logger.error('TLDCache KV 조회 오류', error as Error, { tld }); return null; } } @@ -49,9 +52,9 @@ async function setCachedTLDPrice( await kv.put(key, JSON.stringify(data), { expirationTtl: 3600, // 1시간 }); - console.log(`[TLDCache] SET: ${tld} (${data.krw}원)`); + logger.info('TLDCache SET', { tld, krw: data.krw }); } catch (error) { - console.error('[TLDCache] KV 저장 오류:', error); + logger.error('TLDCache KV 저장 오류', error as Error, { tld }); } } @@ -63,13 +66,13 @@ async function getCachedAllPrices( const key = 'tld_price:all'; const cached = await kv.get(key, 'json'); if (cached) { - console.log('[TLDCache] HIT: all prices'); + logger.info('TLDCache HIT: all prices'); return cached as any[]; } - console.log('[TLDCache] MISS: all prices'); + logger.info('TLDCache MISS: all prices'); return null; } catch (error) { - console.error('[TLDCache] KV 조회 오류:', error); + logger.error('TLDCache KV 조회 오류', error as Error, { key: 'all' }); return null; } } @@ -84,9 +87,9 @@ async function setCachedAllPrices( await kv.put(key, JSON.stringify(prices), { expirationTtl: 3600, // 1시간 }); - console.log(`[TLDCache] SET: all prices (${prices.length}개)`); + logger.info('TLDCache SET: all prices', { count: prices.length }); } catch (error) { - console.error('[TLDCache] KV 저장 오류:', error); + logger.error('TLDCache KV 저장 오류', error as Error, { key: 'all' }); } } @@ -349,7 +352,7 @@ async function callNamecheapApi( query_time_ms: whois.query_time_ms, }; } catch (error) { - console.error('[whois_lookup] 오류:', error); + logger.error('오류', error as Error, { domain: funcArgs.domain }); if (error instanceof RetryError) { return { error: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.' }; } @@ -379,9 +382,9 @@ async function callNamecheapApi( 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}`); + logger.info('user_domains에 추가', { userId, domain: funcArgs.domain }); } catch (dbError) { - console.error('[register_domain] user_domains 추가 실패:', dbError); + logger.error('user_domains 추가 실패', dbError as Error, { userId, domain: funcArgs.domain }); result.warning = result.warning || ''; result.warning += ' (DB 기록 실패 - 수동 추가 필요)'; } @@ -712,11 +715,11 @@ export async function executeManageDomain( db?: D1Database ): Promise { const { action, domain, nameservers, tld } = args; - console.log('[manage_domain] 시작:', { action, domain, telegramUserId, hasDb: !!db }); + logger.info('시작', { action, domain, telegramUserId, hasDb: !!db }); // 소유권 검증 (DB 조회) if (!telegramUserId || !db) { - console.log('[manage_domain] 실패: telegramUserId 또는 db 없음'); + logger.info('실패: telegramUserId 또는 db 없음'); return '🚫 도메인 관리 권한이 없습니다.'; } @@ -737,9 +740,9 @@ export async function executeManageDomain( 'SELECT domain FROM user_domains WHERE user_id = ? AND verified = 1' ).bind(user.id).all<{ domain: string }>(); userDomains = domains.results?.map(d => d.domain) || []; - console.log('[manage_domain] 소유 도메인:', userDomains); + logger.info('소유 도메인', { userDomains }); } catch (error) { - console.log('[manage_domain] DB 오류:', error); + logger.error('DB 오류', error as Error); return '🚫 권한 확인 중 오류가 발생했습니다.'; } @@ -754,17 +757,17 @@ export async function executeManageDomain( db, userId ); - console.log('[manage_domain] 완료:', result?.slice(0, 100)); + logger.info('완료', { result: result?.slice(0, 100) }); return result; } catch (error) { - console.log('[manage_domain] 오류:', error); + logger.error('오류', error as Error); return `🚫 도메인 관리 오류: ${String(error)}`; } } export async function executeSuggestDomains(args: { keywords: string }, env?: Env): Promise { const { keywords } = args; - console.log('[suggest_domains] 시작:', { keywords }); + logger.info('시작', { keywords }); if (!env?.OPENAI_API_KEY) { return '🚫 도메인 추천 기능이 설정되지 않았습니다. (OPENAI_API_KEY 미설정)'; @@ -928,7 +931,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''} return response; } catch (error) { - console.error('[suggestDomains] 오류:', error); + logger.error('오류', error as Error, { keywords }); if (error instanceof RetryError) { return `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`; } diff --git a/src/tools/index.ts b/src/tools/index.ts index fe2f7f6..8963e32 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,7 @@ // Tool Registry - All tools exported from here +import { createLogger } from '../utils/logger'; + +const logger = createLogger('tools'); import { weatherTool, executeWeather } from './weather-tool'; import { searchWebTool, lookupDocsTool, executeSearchWeb, executeLookupDocs } from './search-tool'; @@ -48,7 +51,7 @@ export function selectToolsForMessage(message: string): typeof tools { // 패턴 매칭 없으면 전체 도구 사용 (폴백) if (selectedCategories.size === 1) { - console.log('[ToolSelector] 패턴 매칭 없음 → 전체 도구 사용'); + logger.info('패턴 매칭 없음 → 전체 도구 사용'); return tools; } @@ -58,9 +61,11 @@ export function selectToolsForMessage(message: string): typeof tools { 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(', ')); + logger.info('도구 선택 완료', { + message, + categories: [...selectedCategories].join(', '), + selectedTools: selectedTools.map(t => t.function.name).join(', ') + }); return selectedTools; } diff --git a/src/tools/search-tool.ts b/src/tools/search-tool.ts index f81189d..13a4eec 100644 --- a/src/tools/search-tool.ts +++ b/src/tools/search-tool.ts @@ -1,5 +1,8 @@ import type { Env } from '../types'; import { retryWithBackoff, RetryError } from '../utils/retry'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('search-tool'); // Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회) const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions'; @@ -86,12 +89,12 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom if (translateRes.ok) { const translateData = await translateRes.json() as any; translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query; - console.log(`[search_web] 번역: "${query}" → "${translatedQuery}"`); + logger.info('번역', { original: query, translated: translatedQuery }); } } catch (error) { // 번역 실패 시 원본 사용 (RetryError 포함) if (error instanceof RetryError) { - console.log(`[search_web] 번역 재시도 실패, 원본 사용: ${error.message}`); + logger.info('번역 재시도 실패, 원본 사용', { message: error.message }); } } } @@ -130,7 +133,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom return `🔍 검색 결과: ${queryDisplay}\n\n${results}`; } catch (error) { - console.error('[search_web] 오류:', error); + logger.error('오류', error as Error); if (error instanceof RetryError) { return `🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`; } @@ -171,7 +174,7 @@ export async function executeLookupDocs(args: { library: string; query: string } const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2); return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`; } catch (error) { - console.error('[lookup_docs] 오류:', error); + logger.error('오류', error as Error); if (error instanceof RetryError) { return `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`; } diff --git a/src/utils/circuit-breaker.ts b/src/utils/circuit-breaker.ts index d184d5f..5ac7830 100644 --- a/src/utils/circuit-breaker.ts +++ b/src/utils/circuit-breaker.ts @@ -1,3 +1,6 @@ +import { metrics } from './metrics'; +import { notifyAdmin, NotificationOptions } from '../services/notification'; + /** * Circuit Breaker pattern implementation * @@ -42,6 +45,10 @@ export interface CircuitBreakerOptions { resetTimeoutMs?: number; /** Time window in ms for monitoring failures (default: 120000) */ monitoringWindowMs?: number; + /** Service name for metrics (default: 'unknown') */ + serviceName?: string; + /** Admin notification options (optional) */ + notification?: NotificationOptions; } /** @@ -82,17 +89,25 @@ export class CircuitBreaker { private readonly failureThreshold: number; private readonly resetTimeoutMs: number; private readonly monitoringWindowMs: number; + private readonly serviceName: string; + private readonly notification?: NotificationOptions; constructor(options?: CircuitBreakerOptions) { this.failureThreshold = options?.failureThreshold ?? 5; this.resetTimeoutMs = options?.resetTimeoutMs ?? 60000; this.monitoringWindowMs = options?.monitoringWindowMs ?? 120000; + this.serviceName = options?.serviceName ?? 'unknown'; + this.notification = options?.notification; console.log('[CircuitBreaker] Initialized', { + serviceName: this.serviceName, failureThreshold: this.failureThreshold, resetTimeoutMs: this.resetTimeoutMs, monitoringWindowMs: this.monitoringWindowMs, }); + + // 초기 상태 메트릭 기록 (CLOSED) + metrics.record('circuit_breaker_state', 0, { service: this.serviceName }); } /** @@ -137,6 +152,9 @@ export class CircuitBreaker { this.openedAt = null; this.successCount = 0; this.failureCount = 0; + + // 상태 메트릭 기록 (CLOSED) + metrics.record('circuit_breaker_state', 0, { service: this.serviceName }); } /** @@ -162,6 +180,9 @@ export class CircuitBreaker { if (elapsed >= this.resetTimeoutMs) { console.log('[CircuitBreaker] Reset timeout reached, transitioning to HALF_OPEN'); this.state = CircuitState.HALF_OPEN; + + // 상태 메트릭 기록 (HALF_OPEN) + metrics.record('circuit_breaker_state', 2, { service: this.serviceName }); } } } @@ -177,6 +198,9 @@ export class CircuitBreaker { this.state = CircuitState.CLOSED; this.failures = []; this.openedAt = null; + + // 상태 메트릭 기록 (CLOSED) + metrics.record('circuit_breaker_state', 0, { service: this.serviceName }); } } @@ -197,6 +221,25 @@ export class CircuitBreaker { console.log('[CircuitBreaker] Half-open test failed, reopening circuit'); this.state = CircuitState.OPEN; this.openedAt = now; + + // 상태 메트릭 기록 (OPEN) + metrics.record('circuit_breaker_state', 1, { service: this.serviceName }); + + // 관리자 알림 전송 (HALF_OPEN → OPEN 전환) + if (this.notification) { + notifyAdmin( + 'circuit_breaker', + { + service: this.serviceName, + error: 'Test request failed in HALF_OPEN state', + context: 'Circuit breaker reopened after failed test' + }, + this.notification + ).catch(() => { + // 알림 실패는 무시 (메인 로직에 영향 없음) + }); + } + return; } @@ -208,6 +251,24 @@ export class CircuitBreaker { ); this.state = CircuitState.OPEN; this.openedAt = now; + + // 상태 메트릭 기록 (OPEN) + metrics.record('circuit_breaker_state', 1, { service: this.serviceName }); + + // 관리자 알림 전송 (CLOSED → OPEN 전환) + if (this.notification) { + notifyAdmin( + 'circuit_breaker', + { + service: this.serviceName, + error: error.message || 'Unknown error', + context: `Failure threshold: ${this.failureThreshold}, Current failures: ${this.failures.length}` + }, + this.notification + ).catch(() => { + // 알림 실패는 무시 (메인 로직에 영향 없음) + }); + } } } } @@ -234,6 +295,9 @@ export class CircuitBreaker { throw error; } + // API 호출 카운트 증가 + metrics.increment('api_call_count', { service: this.serviceName }); + try { // Execute the function const result = await fn(); @@ -243,6 +307,9 @@ export class CircuitBreaker { return result; } catch (error) { + // API 에러 카운트 증가 + metrics.increment('api_error_count', { service: this.serviceName }); + // Record failure const err = error instanceof Error ? error : new Error(String(error)); this.onFailure(err); diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 057b769..998b91f 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -5,11 +5,14 @@ * ```typescript * const result = await retryWithBackoff( * async () => fetch('https://api.example.com'), - * { maxRetries: 3, initialDelayMs: 1000 } + * { maxRetries: 3, initialDelayMs: 1000, serviceName: 'external-api' } * ); * ``` */ +import { metrics } from './metrics'; +import { notifyAdmin, NotificationOptions } from '../services/notification'; + /** * Configuration options for retry behavior */ @@ -24,6 +27,10 @@ export interface RetryOptions { backoffMultiplier?: number; /** Whether to add random jitter to delays (default: true) */ jitter?: boolean; + /** Service name for metrics tracking (optional) */ + serviceName?: string; + /** Notification options for admin alerts (optional) */ + notification?: NotificationOptions; } /** @@ -103,6 +110,8 @@ export async function retryWithBackoff( maxDelayMs = 10000, backoffMultiplier = 2, jitter = true, + serviceName = 'unknown', + notification, } = options || {}; let lastError: Error; @@ -127,6 +136,22 @@ export async function retryWithBackoff( `[Retry] All ${maxRetries + 1} attempts failed. Last error:`, lastError.message ); + + // Send admin notification if configured + if (notification) { + notifyAdmin( + 'retry_exhausted', + { + service: serviceName, + error: lastError.message, + context: `All ${maxRetries + 1} attempts failed`, + }, + notification + ).catch(() => { + // Ignore notification failures + }); + } + throw new RetryError( `Operation failed after ${maxRetries + 1} attempts: ${lastError.message}`, maxRetries + 1, @@ -134,6 +159,14 @@ export async function retryWithBackoff( ); } + // Track retry metric (only for actual retries, not first attempt) + if (attempt > 0) { + metrics.increment('retry_count', { + service: serviceName, + attempt: String(attempt), + }); + } + // Calculate delay for next retry const delay = calculateDelay( attempt, diff --git a/web/index.html b/web/index.html index 742db8f..3f34053 100644 --- a/web/index.html +++ b/web/index.html @@ -177,6 +177,95 @@ background: linear-gradient(180deg, transparent 60%, #ffc078 60%); padding: 0 4px; } + + /* 채팅 위젯 스타일 */ + .chat-widget-container { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 100; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1rem; + } + + .chat-frame-wrapper { + width: 380px; + height: 600px; + background: white; + border: 2px solid #1e1e1e; + border-radius: 4px; + box-shadow: 6px 6px 0 rgba(0,0,0,0.2); + transform: scale(0); + transform-origin: bottom right; + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + overflow: hidden; + display: flex; + flex-direction: column; + } + + .chat-frame-wrapper.open { + transform: scale(1); + } + + .chat-header { + background: #1971c2; + color: white; + padding: 0.75rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid #1e1e1e; + font-family: 'Caveat', cursive; + font-size: 1.25rem; + } + + .chat-iframe { + flex: 1; + width: 100%; + height: 100%; + border: none; + } + + .chat-toggle-btn { + width: 64px; + height: 64px; + background: #1971c2; + color: white; + border: 2px solid #1e1e1e; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 4px 4px 0 #1e1e1e; + transition: all 0.2s ease; + font-size: 1.5rem; + position: relative; + } + + .chat-toggle-btn:hover { + transform: translate(-2px, -2px); + box-shadow: 6px 6px 0 #1e1e1e; + } + + .chat-toggle-btn:active { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 #1e1e1e; + } + + /* 모바일 대응 */ + @media (max-width: 480px) { + .chat-widget-container { + bottom: 1rem; + right: 1rem; + } + .chat-frame-wrapper { + width: calc(100vw - 2rem); + height: 80vh; + } + } @@ -881,5 +970,54 @@ + +
+ +
+
+
+ 💬 실시간 상담 +
+ +
+ +
+ + + +
+ + +