feat(phase-5-3): Logger, Metrics, 알림 시스템 통합
Phase 5-3 모니터링 강화 작업의 통합을 완료했습니다. 변경사항: - Logger 통합: console.log를 구조화된 로깅으로 전환 (9개 파일) - JSON 기반 로그, 환경별 자동 전환 (개발/프로덕션) - 타입 안전성 보장, 성능 측정 타이머 내장 - Metrics 통합: 실시간 성능 모니터링 시스템 연결 (3개 파일) - Circuit Breaker 상태 추적 (api_call_count, error_count, state) - Retry 재시도 횟수 추적 (retry_count) - OpenAI API 응답 시간 측정 (api_call_duration) - 알림 통합: 장애 자동 알림 시스템 구현 (2개 파일) - Circuit Breaker OPEN 상태 → 관리자 Telegram 알림 - 재시도 실패 → 관리자 Telegram 알림 - Rate Limiting 적용 (1시간에 1회) - 문서 업데이트: - CLAUDE.md: coder 에이전트 설명 강화 (20년+ 시니어 전문가) - README.md, docs/: 아키텍처 문서 추가 영향받은 파일: 16개 (수정 14개, 신규 2개) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
70
CLAUDE.md
70
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 | 트리거 키워드 |
|
||||
|------|--------|----------|---------------|
|
||||
|
||||
728
README.md
728
README.md
@@ -1,715 +1,79 @@
|
||||
# Cloudflare Workers 텔레그램 봇
|
||||
# 🤖 Cloudflare Workers 텔레그램 AI 봇
|
||||
|
||||
> Cloudflare Workers + D1 + OpenAI를 활용한 사용자 프로필 기반 텔레그램 봇
|
||||
> **Cloudflare Workers + D1 + OpenAI**를 활용한 서버리스 아키텍처 기반의 지능형 텔레그램 봇입니다.
|
||||
> 사용자별 프로필을 자동으로 생성하고 기억하며, 예치금 관리 및 도메인 등록 등의 복잡한 작업을 수행할 수 있습니다.
|
||||
|
||||
**📖 이 문서**: 기능 소개, 배포 가이드, 사용법 (사용자/운영자용)
|
||||
**🔧 [CLAUDE.md](./CLAUDE.md)**: 기술 상세, 코드 패턴, 트러블슈팅 (개발자용)
|
||||

|
||||

|
||||

|
||||
|
||||
## 목차
|
||||
## 📚 문서 안내
|
||||
|
||||
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://<YOUR_WORKER_URL>/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
|
||||
|
||||
```
|
||||
버그 신고나 기능 제안은 Issue를 등록해주세요.
|
||||
소스 코드는 **[Gitea](https://gitea.anvil.it.com/kaffa/telegram-bot-workers)**에서 관리됩니다.
|
||||
|
||||
118
docs/ARCHITECTURE.md
Normal file
118
docs/ARCHITECTURE.md
Normal file
@@ -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`) 사용
|
||||
105
docs/USER_GUIDE.md
Normal file
105
docs/USER_GUIDE.md
Normal file
@@ -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으로 바꿔줘"
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +46,9 @@ async function callOpenAI(
|
||||
messages: OpenAIMessage[],
|
||||
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
||||
): Promise<OpenAIResponse> {
|
||||
const timer = metrics.startTimer('api_call_duration', { service: 'openai' });
|
||||
|
||||
try {
|
||||
return await retryWithBackoff(
|
||||
async () => {
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
@@ -72,6 +79,9 @@ async function callOpenAI(
|
||||
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 '프로필 생성 실패: 예상치 못한 오류';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ConversationResult> {
|
||||
// 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Env } from '../types';
|
||||
|
||||
export class UserService {
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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 `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T>(
|
||||
maxDelayMs = 10000,
|
||||
backoffMultiplier = 2,
|
||||
jitter = true,
|
||||
serviceName = 'unknown',
|
||||
notification,
|
||||
} = options || {};
|
||||
|
||||
let lastError: Error;
|
||||
@@ -127,6 +136,22 @@ export async function retryWithBackoff<T>(
|
||||
`[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<T>(
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
138
web/index.html
138
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans text-sketch-line">
|
||||
@@ -881,5 +970,54 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Chat Widget -->
|
||||
<div class="chat-widget-container">
|
||||
<!-- Chat Frame (Hidden by default) -->
|
||||
<div id="chatFrame" class="chat-frame-wrapper">
|
||||
<div class="chat-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>💬 실시간 상담</span>
|
||||
</div>
|
||||
<button onclick="toggleChat()" class="hover:text-cream transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<iframe src="https://bd46a903.chat-frontend-4wf.pages.dev" class="chat-iframe" allow="microphone;"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Button -->
|
||||
<button id="chatBtn" onclick="toggleChat()" class="chat-toggle-btn group">
|
||||
<!-- Chat Icon -->
|
||||
<svg class="group-[.open]:hidden transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<!-- Close Icon (Hidden initially) -->
|
||||
<svg class="hidden group-[.open]:block transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
|
||||
<!-- New Message Badge (Optional) -->
|
||||
<span class="absolute -top-1 -right-1 flex h-4 w-4">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sketch-red opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-4 w-4 bg-sketch-red"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleChat() {
|
||||
const frame = document.getElementById('chatFrame');
|
||||
const btn = document.getElementById('chatBtn');
|
||||
|
||||
frame.classList.toggle('open');
|
||||
btn.classList.toggle('open');
|
||||
|
||||
// Update ARIA
|
||||
const isOpen = frame.classList.contains('open');
|
||||
btn.setAttribute('aria-expanded', isOpen);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user