commit 1e71e035e7e7f523f3a490b210ea04318f0bca92 Author: kappa Date: Wed Jan 14 13:00:44 2026 +0900 Initial commit: Telegram bot with Cloudflare Workers - OpenAI GPT-4o-mini with Function Calling - Cloudflare D1 for user profiles and message buffer - Sliding window (3 summaries max) for infinite context - Tools: weather, search, time, calculator - Workers AI fallback support - Webhook security with rate limiting Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f240cbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +.npm + +# Build output +dist/ +.wrangler/ + +# Environment & Secrets +.env +.env.* +.dev.vars + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Logs +*.log +npm-debug.log* + +# Test coverage +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..558a364 --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# Cloudflare Workers 텔레그램 봇 + +> Cloudflare Workers + D1 + OpenAI를 활용한 사용자 프로필 기반 텔레그램 봇 + +## 목차 + +1. [개요](#개요) +2. [아키텍처](#아키텍처) +3. [Function Calling](#function-calling) +4. [프로젝트 구조](#프로젝트-구조) +5. [배포 가이드](#배포-가이드) +6. [보안 설정](#보안-설정) +7. [봇 명령어](#봇-명령어) + +--- + +## 개요 + +### 주요 기능 + +- **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원 +- **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축 +- **Function Calling**: 날씨, 검색, 시간, 계산 등 AI가 자동으로 도구 호출 +- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억 +- **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공 +- **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환 + +### 기술 스택 + +| 서비스 | 용도 | +|--------|------| +| **Workers** | 서버리스 런타임 | +| **D1** | SQLite 데이터베이스 | +| **OpenAI** | GPT-4o-mini + Function Calling | +| **Workers AI** | 폴백용 (Llama 3.1 8B) | + +--- + +## 아키텍처 + +### 메시지 처리 흐름 + +``` +[사용자 메시지] + │ + ▼ +┌──────────────────┐ +│ Cloudflare │ +│ Worker │ +└──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ OpenAI API │ ← GPT-4o-mini +│ (Function Call) │ 도구 호출 자동 판단 +└──────────────────┘ + │ + ┌───┴───┬───────┬───────┐ + ▼ ▼ ▼ ▼ +[날씨] [검색] [시간] [계산] → 외부 API + │ │ │ │ + └───┬───┴───────┴───────┘ + ▼ +┌──────────────────┐ +│ 최종 응답 생성 │ +└──────────────────┘ + │ + ▼ +[D1 저장] → [Telegram 응답] +``` + +### 사용자 프로필 시스템 + +``` +[사용자 메시지] + │ + ▼ +┌──────────────────┐ +│ message_buffer │ ← 최대 19개 (20개 되면 프로필 업데이트) +└──────────────────┘ + │ 20개 도달 + ▼ +┌──────────────────┐ +│ 프로필 분석 │ ← 사용자 발언만 추출하여 분석 +│ (OpenAI) │ 봇 응답은 무시 +└──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ summaries │ ← 최근 3개만 유지 (슬라이딩 윈도우) +│ [v1] [v2] [v3] │ +└──────────────────┘ +``` + +### 프로필 분석 내용 + +| 추출 정보 | 설명 | +|-----------|------| +| **관심사** | 사용자가 자주 언급하는 주제 | +| **목표** | 해결하려는 문제, 달성하려는 것 | +| **맥락** | 직업, 상황, 배경 정보 | +| **선호도** | 좋아하는 것, 싫어하는 것 | +| **질문 패턴** | 무엇에 대해 알고 싶어하는지 | + +--- + +## Function Calling + +OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출합니다. + +### 지원 기능 + +| 기능 | 예시 질문 | API | +|------|-----------|-----| +| **날씨** | "서울 날씨", "도쿄 날씨 알려줘" | wttr.in | +| **검색** | "파이썬이 뭐야", "클라우드플레어란" | DuckDuckGo | +| **시간** | "지금 몇 시야", "뉴욕 시간" | 내장 | +| **계산** | "123 * 456", "100의 20%" | 내장 | + +### 동작 방식 + +``` +사용자: "서울 날씨 어때?" + │ + ▼ +OpenAI: "get_weather 함수를 호출해야겠다" + │ + ▼ +Worker: wttr.in API 호출 → 날씨 데이터 수신 + │ + ▼ +OpenAI: 날씨 데이터를 자연어로 응답 생성 + │ + ▼ +응답: "🌤 서울 날씨\n온도: 5°C\n습도: 45%..." +``` + +--- + +## 프로젝트 구조 + +``` +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 +│ ├── n8n-service.ts # n8n 연동 (선택) +│ └── commands.ts # 봇 명령어 핸들러 +├── schema.sql # D1 스키마 +├── wrangler.toml # Wrangler 설정 +├── n8n-workflow-example.json # n8n 워크플로우 예시 +├── package.json +├── tsconfig.json +└── README.md +``` + +--- + +## 배포 가이드 + +### 1. 프로젝트 설정 + +```bash +cd telegram-bot-workers +npm install +``` + +### 2. D1 데이터베이스 + +현재 설정: + +```toml +[[d1_databases]] +binding = "DB" +database_name = "telegram-conversations" +database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" +``` + +스키마: +- `users` - 사용자 정보 +- `message_buffer` - 메시지 임시 저장 +- `summaries` - 프로필 저장 + +### 3. Secrets 설정 + +```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 +``` + +### Vault 연동 (선택) + +API 키는 HashiCorp Vault에서 중앙 관리됩니다. + +```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 +``` + +> **참고**: Vault 서버: `https://vault.anvil.it.com` + +### 4. 배포 + +```bash +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 +``` + +--- + +## 보안 설정 + +### 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` + +--- + +## 봇 명령어 + +| 명령어 | 설명 | +|--------|------| +| `/start` | 봇 시작, 기능 소개 | +| `/help` | 도움말 | +| `/context` | 현재 컨텍스트 상태 (버퍼 수, 프로필 버전) | +| `/profile` | 내 프로필 보기 | +| `/stats` | 대화 통계 | +| `/reset` | 모든 대화 기록 삭제 | + +--- + +## 설정값 + +`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" + +[[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 | + +--- + +## 참고 + +- [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) diff --git a/n8n-workflow-example.json b/n8n-workflow-example.json new file mode 100644 index 0000000..2ca8f22 --- /dev/null +++ b/n8n-workflow-example.json @@ -0,0 +1,445 @@ +{ + "name": "Telegram Bot Handler", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "telegram-bot", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-1", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [250, 300], + "webhookId": "telegram-bot" + }, + { + "parameters": { + "rules": { + "rules": [ + { + "outputKey": "weather", + "conditions": { + "options": { "version": 2 }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.type }}", + "rightValue": "weather", + "operator": { "type": "string", "operation": "equals" } + } + ] + } + }, + { + "outputKey": "search", + "conditions": { + "options": { "version": 2 }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.type }}", + "rightValue": "search", + "operator": { "type": "string", "operation": "equals" } + } + ] + } + }, + { + "outputKey": "translate", + "conditions": { + "options": { "version": 2 }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.type }}", + "rightValue": "translate", + "operator": { "type": "string", "operation": "equals" } + } + ] + } + }, + { + "outputKey": "image", + "conditions": { + "options": { "version": 2 }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.type }}", + "rightValue": "image", + "operator": { "type": "string", "operation": "equals" } + } + ] + } + }, + { + "outputKey": "summarize_url", + "conditions": { + "options": { "version": 2 }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.type }}", + "rightValue": "summarize_url", + "operator": { "type": "string", "operation": "equals" } + } + ] + } + }, + { + "outputKey": "default", + "conditions": { + "options": { "version": 2 }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.type }}", + "rightValue": "", + "operator": { "type": "string", "operation": "notEmpty" } + } + ] + } + } + ] + }, + "options": {} + }, + "id": "switch-1", + "name": "Route by Type", + "type": "n8n-nodes-base.switch", + "typeVersion": 3, + "position": [480, 300] + }, + { + "parameters": { + "method": "GET", + "url": "=https://api.openweathermap.org/data/2.5/weather?q={{ $json.message.match(/[가-힣a-zA-Z]+/)?.[0] || 'Seoul' }}&appid={{ $env.OPENWEATHER_API_KEY }}&units=metric&lang=kr", + "options": {} + }, + "id": "weather-api", + "name": "Weather API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [720, 100] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "reply", + "name": "reply", + "value": "={{ '🌤 ' + $json.name + ' 날씨\\n온도: ' + $json.main.temp + '°C\\n체감: ' + $json.main.feels_like + '°C\\n습도: ' + $json.main.humidity + '%\\n' + $json.weather[0].description }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "weather-format", + "name": "Format Weather", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [940, 100] + }, + { + "parameters": { + "model": "gpt-4o-mini", + "messages": { + "values": [ + { + "content": "=다음 질문에 대해 웹 검색 결과를 바탕으로 답변해주세요: {{ $('Webhook').item.json.message }}" + } + ] + }, + "options": {} + }, + "id": "search-ai", + "name": "AI Search", + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.4, + "position": [720, 220] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "reply", + "name": "reply", + "value": "={{ $json.message.content }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "search-format", + "name": "Format Search", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [940, 220] + }, + { + "parameters": { + "method": "POST", + "url": "https://api-free.deepl.com/v2/translate", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "text", + "value": "={{ $('Webhook').item.json.message }}" + }, + { + "name": "target_lang", + "value": "EN" + } + ] + }, + "options": { + "headers": { + "header": [ + { + "name": "Authorization", + "value": "=DeepL-Auth-Key {{ $env.DEEPL_API_KEY }}" + } + ] + } + } + }, + "id": "translate-api", + "name": "Translate API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [720, 340] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "reply", + "name": "reply", + "value": "=🌐 번역 결과:\\n{{ $json.translations[0].text }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "translate-format", + "name": "Format Translate", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [940, 340] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/images/generations", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Bearer {{ $env.OPENAI_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"dall-e-3\",\n \"prompt\": \"{{ $('Webhook').item.json.message }}\",\n \"n\": 1,\n \"size\": \"1024x1024\"\n}", + "options": {} + }, + "id": "image-api", + "name": "Image Generation", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [720, 460] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "reply", + "name": "reply", + "value": "=🎨 이미지 생성 완료!\\n{{ $json.data[0].url }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "image-format", + "name": "Format Image", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [940, 460] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $('Webhook').item.json.message.match(/https?:\\/\\/[^\\s]+/)?.[0] }}", + "options": {} + }, + "id": "fetch-url", + "name": "Fetch URL", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [720, 580] + }, + { + "parameters": { + "model": "gpt-4o-mini", + "messages": { + "values": [ + { + "content": "=다음 웹페이지 내용을 한국어로 300자 이내로 요약해주세요:\\n\\n{{ $json.data.substring(0, 5000) }}" + } + ] + }, + "options": {} + }, + "id": "summarize-ai", + "name": "Summarize URL", + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.4, + "position": [940, 580] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "reply", + "name": "reply", + "value": "=📄 URL 요약:\\n{{ $json.message.content }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "summarize-format", + "name": "Format Summary", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [1160, 580] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "reply", + "name": "reply", + "value": "=❓ 지원하지 않는 기능입니다: {{ $('Webhook').item.json.type }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "default-response", + "name": "Default Response", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [720, 700] + }, + { + "parameters": { + "options": {} + }, + "id": "respond-1", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1380, 300] + } + ], + "connections": { + "Webhook": { + "main": [ + [{ "node": "Route by Type", "type": "main", "index": 0 }] + ] + }, + "Route by Type": { + "main": [ + [{ "node": "Weather API", "type": "main", "index": 0 }], + [{ "node": "AI Search", "type": "main", "index": 0 }], + [{ "node": "Translate API", "type": "main", "index": 0 }], + [{ "node": "Image Generation", "type": "main", "index": 0 }], + [{ "node": "Fetch URL", "type": "main", "index": 0 }], + [{ "node": "Default Response", "type": "main", "index": 0 }] + ] + }, + "Weather API": { + "main": [ + [{ "node": "Format Weather", "type": "main", "index": 0 }] + ] + }, + "Format Weather": { + "main": [ + [{ "node": "Respond to Webhook", "type": "main", "index": 0 }] + ] + }, + "AI Search": { + "main": [ + [{ "node": "Format Search", "type": "main", "index": 0 }] + ] + }, + "Format Search": { + "main": [ + [{ "node": "Respond to Webhook", "type": "main", "index": 0 }] + ] + }, + "Translate API": { + "main": [ + [{ "node": "Format Translate", "type": "main", "index": 0 }] + ] + }, + "Format Translate": { + "main": [ + [{ "node": "Respond to Webhook", "type": "main", "index": 0 }] + ] + }, + "Image Generation": { + "main": [ + [{ "node": "Format Image", "type": "main", "index": 0 }] + ] + }, + "Format Image": { + "main": [ + [{ "node": "Respond to Webhook", "type": "main", "index": 0 }] + ] + }, + "Fetch URL": { + "main": [ + [{ "node": "Summarize URL", "type": "main", "index": 0 }] + ] + }, + "Summarize URL": { + "main": [ + [{ "node": "Format Summary", "type": "main", "index": 0 }] + ] + }, + "Format Summary": { + "main": [ + [{ "node": "Respond to Webhook", "type": "main", "index": 0 }] + ] + }, + "Default Response": { + "main": [ + [{ "node": "Respond to Webhook", "type": "main", "index": 0 }] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eb91254 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "telegram-summary-bot", + "version": "1.0.0", + "description": "Telegram bot with rolling summary using Cloudflare Workers + D1", + "main": "src/index.ts", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:create": "wrangler d1 create telegram-summary-db", + "db:init": "wrangler d1 execute telegram-summary-db --file=schema.sql", + "db:init:local": "wrangler d1 execute telegram-summary-db --local --file=schema.sql", + "tail": "wrangler tail" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241127.0", + "typescript": "^5.3.3", + "wrangler": "^3.93.0" + }, + "keywords": [ + "telegram", + "bot", + "cloudflare", + "workers", + "d1", + "ai" + ] +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..d464e79 --- /dev/null +++ b/schema.sql @@ -0,0 +1,42 @@ +-- Telegram Bot Rolling Summary Schema +-- D1 Database for Cloudflare Workers + +-- 사용자 테이블 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id TEXT UNIQUE NOT NULL, + username TEXT, + first_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 메시지 버퍼 (요약 전 임시 저장) +CREATE TABLE IF NOT EXISTS message_buffer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + chat_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('user', 'bot')), + message TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- 요약 저장 테이블 (슬라이딩 윈도우: 최대 3개만 유지) +CREATE TABLE IF NOT EXISTS summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + chat_id TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1, + summary TEXT NOT NULL, + message_count INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS idx_buffer_user ON message_buffer(user_id); +CREATE INDEX IF NOT EXISTS idx_buffer_chat ON message_buffer(user_id, chat_id); +CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id); +CREATE INDEX IF NOT EXISTS idx_summary_latest ON summaries(user_id, chat_id, generation DESC); +CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id); diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..1b22c05 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,124 @@ +import { Env } from './types'; +import { getConversationContext, getLatestSummary } from './summary-service'; + +export async function handleCommand( + env: Env, + userId: number, + chatId: string, + command: string, + _args: string +): Promise { + const config = { + threshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10), + maxSummaries: parseInt(env.MAX_SUMMARIES_PER_USER || '3', 10), + }; + + switch (command) { + case '/start': + return `👋 안녕하세요! AI 어시스턴트입니다. + +특징: +• 대화를 통해 당신을 이해합니다 +• ${config.threshold}개 메시지마다 프로필 업데이트 +• 무한한 대화 기억 가능 + +명령어: +/help - 도움말 +/context - 현재 컨텍스트 확인 +/profile - 내 프로필 보기 +/stats - 대화 통계 +/reset - 대화 초기화`; + + case '/help': + return `📖 도움말 + +기본 명령어: +/start - 봇 시작 +/context - 현재 컨텍스트 상태 +/profile - 내 프로필 보기 +/stats - 대화 통계 +/reset - 모든 대화 기록 삭제 + +사용자 프로필 시스템: +• 메시지 ${config.threshold}개마다 프로필 업데이트 +• 사용자 발언 위주로 관심사/목표/맥락 분석 +• 의미 없는 대화(인사, 확인 등)는 제외 + +일반 메시지를 보내면 AI가 응답합니다.`; + + case '/context': { + const ctx = await getConversationContext(env.DB, userId, chatId); + const remaining = config.threshold - ctx.recentMessages.length; + + return `📊 현재 컨텍스트 + +분석된 메시지: ${ctx.previousSummary?.message_count || 0}개 +버퍼 메시지: ${ctx.recentMessages.length}개 +프로필 버전: ${ctx.previousSummary?.generation || 0} +총 메시지: ${ctx.totalMessages}개 + +💡 ${remaining > 0 ? `${remaining}개 메시지 후 프로필 업데이트` : '업데이트 대기 중'}`; + } + + case '/profile': + case '/summary': { + const summary = await getLatestSummary(env.DB, userId, chatId); + if (!summary) { + return '📭 아직 프로필이 없습니다.\n대화를 더 나눠보세요!'; + } + const createdAt = new Date(summary.created_at).toLocaleString('ko-KR', { + timeZone: 'Asia/Seoul', + }); + return `👤 내 프로필 (v${summary.generation}) + +${summary.summary} + +분석된 메시지: ${summary.message_count}개 +업데이트: ${createdAt}`; + } + + case '/stats': { + const ctx = await getConversationContext(env.DB, userId, chatId); + const profileCount = await env.DB + .prepare('SELECT COUNT(*) as cnt FROM summaries WHERE user_id = ?') + .bind(userId) + .first<{ cnt: number }>(); + + return `📈 대화 통계 + +총 메시지: ${ctx.totalMessages}개 +프로필 버전: ${ctx.previousSummary?.generation || 0} +저장된 프로필: ${profileCount?.cnt || 0}개 +버퍼 대기: ${ctx.recentMessages.length}개`; + } + + case '/reset': { + await env.DB.batch([ + env.DB.prepare('DELETE FROM message_buffer WHERE user_id = ?').bind(userId), + env.DB.prepare('DELETE FROM summaries WHERE user_id = ?').bind(userId), + ]); + return '🗑️ 모든 대화 기록과 요약이 초기화되었습니다.'; + } + + case '/debug': { + // 디버그용 명령어 (개발 시 유용) + const ctx = await getConversationContext(env.DB, userId, chatId); + const recentMsgs = ctx.recentMessages.slice(-5).map((m, i) => + `${i + 1}. [${m.role}] ${m.message.substring(0, 30)}...` + ).join('\n'); + + return `🔧 디버그 정보 + +User ID: ${userId} +Chat ID: ${chatId} +Buffer Count: ${ctx.recentMessages.length} +Summary Gen: ${ctx.previousSummary?.generation || 0} + +최근 버퍼 (5개): +${recentMsgs || '(비어있음)'}`; + } + + default: + return '❓ 알 수 없는 명령어입니다.\n/help를 입력해보세요.'; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d221b55 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,201 @@ +import { Env, TelegramUpdate } from './types'; +import { validateWebhookRequest, checkRateLimit } from './security'; +import { sendMessage, setWebhook, getWebhookInfo, sendChatAction } from './telegram'; +import { + addToBuffer, + processAndSummarize, + generateAIResponse, +} from './summary-service'; +import { handleCommand } from './commands'; + +// 사용자 조회/생성 +async function getOrCreateUser( + db: D1Database, + telegramId: string, + firstName: string, + username?: string +): Promise { + const existing = await db + .prepare('SELECT id FROM users WHERE telegram_id = ?') + .bind(telegramId) + .first<{ id: number }>(); + + if (existing) { + // 마지막 활동 시간 업데이트 + await db + .prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .bind(existing.id) + .run(); + return existing.id; + } + + // 새 사용자 생성 + const result = await db + .prepare('INSERT INTO users (telegram_id, first_name, username) VALUES (?, ?, ?)') + .bind(telegramId, firstName, username || null) + .run(); + + return result.meta.last_row_id as number; +} + +// 메시지 처리 +async function handleMessage( + env: Env, + update: TelegramUpdate +): Promise { + if (!update.message?.text) return; + + const { message } = update; + const chatId = message.chat.id; + const chatIdStr = chatId.toString(); + const text = message.text; + const telegramUserId = message.from.id.toString(); + + // Rate Limiting 체크 + if (!checkRateLimit(telegramUserId)) { + await sendMessage( + env.BOT_TOKEN, + chatId, + '⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.' + ); + return; + } + + // 사용자 처리 + const userId = await getOrCreateUser( + env.DB, + telegramUserId, + message.from.first_name, + message.from.username + ); + + let responseText: string; + + // 명령어 처리 + if (text.startsWith('/')) { + const [command, ...argParts] = text.split(' '); + const args = argParts.join(' '); + responseText = await handleCommand(env, userId, chatIdStr, command, args); + } else { + // 타이핑 표시 + await sendChatAction(env.BOT_TOKEN, chatId, 'typing'); + + // 1. 사용자 메시지 버퍼에 추가 + await addToBuffer(env.DB, userId, chatIdStr, 'user', text); + + try { + // 2. AI 응답 생성 + responseText = await generateAIResponse(env, userId, chatIdStr, text); + + // 3. 봇 응답 버퍼에 추가 + await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); + + // 4. 임계값 도달시 프로필 업데이트 + const { summarized } = await processAndSummarize(env, userId, chatIdStr); + + if (summarized) { + responseText += '\n\n👤 프로필이 업데이트되었습니다.'; + } + } catch (error) { + console.error('AI Response error:', error); + responseText = `⚠️ AI 응답 생성 중 오류가 발생했습니다.\n\n${String(error)}`; + } + } + + await sendMessage(env.BOT_TOKEN, chatId, responseText); +} + +export default { + // HTTP 요청 핸들러 + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Webhook 설정 엔드포인트 + if (url.pathname === '/setup-webhook') { + if (!env.BOT_TOKEN) { + return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 }); + } + if (!env.WEBHOOK_SECRET) { + return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 }); + } + + const webhookUrl = `${url.origin}/webhook`; + const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET); + return Response.json(result); + } + + // Webhook 정보 조회 + if (url.pathname === '/webhook-info') { + if (!env.BOT_TOKEN) { + return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 }); + } + const result = await getWebhookInfo(env.BOT_TOKEN); + return Response.json(result); + } + + // 헬스 체크 + if (url.pathname === '/health') { + try { + const userCount = await env.DB + .prepare('SELECT COUNT(*) as cnt FROM users') + .first<{ cnt: number }>(); + + const summaryCount = await env.DB + .prepare('SELECT COUNT(*) as cnt FROM summaries') + .first<{ cnt: number }>(); + + return Response.json({ + status: 'ok', + timestamp: new Date().toISOString(), + stats: { + users: userCount?.cnt || 0, + summaries: summaryCount?.cnt || 0, + }, + }); + } catch (error) { + return Response.json({ + status: 'error', + error: String(error), + }, { status: 500 }); + } + } + + // Telegram Webhook 처리 + if (url.pathname === '/webhook') { + // 보안 검증 + const validation = await validateWebhookRequest(request, env); + + if (!validation.valid) { + console.error('Webhook validation failed:', validation.error); + return new Response(validation.error, { status: 401 }); + } + + try { + await handleMessage(env, validation.update!); + return new Response('OK'); + } catch (error) { + console.error('Message handling error:', error); + return new Response('Error', { status: 500 }); + } + } + + // 루트 경로 + if (url.pathname === '/') { + return new Response(` +Telegram Rolling Summary Bot + +Endpoints: + GET /health - Health check + GET /webhook-info - Webhook status + GET /setup-webhook - Configure webhook + POST /webhook - Telegram webhook (authenticated) + +Documentation: https://github.com/your-repo + `.trim(), { + headers: { 'Content-Type': 'text/plain' }, + }); + } + + return new Response('Not Found', { status: 404 }); + }, +}; diff --git a/src/n8n-service.ts b/src/n8n-service.ts new file mode 100644 index 0000000..f8706c5 --- /dev/null +++ b/src/n8n-service.ts @@ -0,0 +1,155 @@ +import { Env, IntentAnalysis, N8nResponse } from './types'; + +// n8n으로 처리할 기능 목록 +const N8N_CAPABILITIES = [ + 'weather', // 날씨 + 'search', // 검색 + 'image', // 이미지 생성 + 'translate', // 번역 + 'schedule', // 일정 + 'reminder', // 알림 + 'news', // 뉴스 + 'calculate', // 계산 + 'summarize_url', // URL 요약 +]; + +// AI가 의도를 분석하여 n8n 호출 여부 결정 +export async function analyzeIntent( + ai: Ai, + userMessage: string +): Promise { + const prompt = `사용자 메시지를 분석하여 어떤 처리가 필요한지 JSON으로 응답하세요. + +## 외부 기능이 필요한 경우 (action: "n8n") +- 날씨 정보: type = "weather" +- 웹 검색: type = "search" +- 이미지 생성: type = "image" +- 번역: type = "translate" +- 일정/캘린더: type = "schedule" +- 알림 설정: type = "reminder" +- 뉴스: type = "news" +- 복잡한 계산: type = "calculate" +- URL/링크 요약: type = "summarize_url" + +## 일반 대화인 경우 (action: "chat") +- 인사, 잡담, 질문, 조언 요청 등 + +## 응답 형식 (JSON만 출력) +{"action": "n8n", "type": "weather", "confidence": 0.9} +또는 +{"action": "chat", "confidence": 0.95} + +## 사용자 메시지 +${userMessage} + +JSON:`; + + try { + const response = await ai.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [{ role: 'user', content: prompt }], + max_tokens: 100, + }); + + const text = response.response || ''; + + // JSON 추출 + const jsonMatch = text.match(/\{[^}]+\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + return { + action: parsed.action || 'chat', + type: parsed.type, + confidence: parsed.confidence || 0.5, + }; + } + } catch (error) { + console.error('Intent analysis error:', error); + } + + // 기본값: 일반 대화 + return { action: 'chat', confidence: 0.5 }; +} + +// n8n Webhook 호출 +export async function callN8n( + env: Env, + type: string, + userMessage: string, + userId: number, + chatId: string, + userProfile?: string | null +): Promise { + if (!env.N8N_WEBHOOK_URL) { + return { error: 'n8n이 설정되지 않았습니다.' }; + } + + const webhookUrl = `${env.N8N_WEBHOOK_URL}/webhook/telegram-bot`; + + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type, + message: userMessage, + user_id: userId, + chat_id: chatId, + profile: userProfile, + timestamp: new Date().toISOString(), + }), + }); + + if (!response.ok) { + console.error('n8n error:', response.status, await response.text()); + return { error: `n8n 호출 실패 (${response.status})` }; + } + + const data = await response.json() as N8nResponse; + return data; + } catch (error) { + console.error('n8n fetch error:', error); + return { error: 'n8n 연결 실패' }; + } +} + +// 스마트 라우팅: AI 판단 → n8n 또는 로컬 AI +export async function smartRoute( + env: Env, + userMessage: string, + userId: number, + chatId: string, + userProfile?: string | null +): Promise<{ useN8n: boolean; n8nType?: string; n8nResponse?: N8nResponse }> { + // n8n URL이 없으면 항상 로컬 AI 사용 + if (!env.N8N_WEBHOOK_URL) { + return { useN8n: false }; + } + + // 의도 분석 + const intent = await analyzeIntent(env.AI, userMessage); + + // n8n 호출이 필요하고 신뢰도가 높은 경우 + if (intent.action === 'n8n' && intent.confidence >= 0.7 && intent.type) { + const n8nResponse = await callN8n( + env, + intent.type, + userMessage, + userId, + chatId, + userProfile + ); + + // n8n 응답이 성공적인 경우 + if (n8nResponse.reply && !n8nResponse.error) { + return { + useN8n: true, + n8nType: intent.type, + n8nResponse, + }; + } + } + + return { useN8n: false }; +} diff --git a/src/openai-service.ts b/src/openai-service.ts new file mode 100644 index 0000000..e36b140 --- /dev/null +++ b/src/openai-service.ts @@ -0,0 +1,272 @@ +import { Env } from './types'; + +interface OpenAIMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +interface OpenAIResponse { + choices: { + message: OpenAIMessage; + finish_reason: string; + }[]; +} + +// 사용 가능한 도구 정의 +const tools = [ + { + type: 'function', + function: { + name: 'get_weather', + description: '특정 도시의 현재 날씨 정보를 가져옵니다', + parameters: { + type: 'object', + properties: { + city: { + type: 'string', + description: '도시 이름 (예: Seoul, Tokyo, New York)', + }, + }, + required: ['city'], + }, + }, + }, + { + type: 'function', + function: { + name: 'search_web', + description: '웹에서 정보를 검색합니다', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: '검색 쿼리', + }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'get_current_time', + description: '현재 시간을 가져옵니다', + parameters: { + type: 'object', + properties: { + timezone: { + type: 'string', + description: '타임존 (예: Asia/Seoul, UTC)', + }, + }, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'calculate', + description: '수학 계산을 수행합니다', + parameters: { + type: 'object', + properties: { + expression: { + type: 'string', + description: '계산할 수식 (예: 2+2, 100*5)', + }, + }, + required: ['expression'], + }, + }, + }, +]; + +// 도구 실행 +async function executeTool(name: string, args: Record): Promise { + switch (name) { + case 'get_weather': { + const city = args.city || 'Seoul'; + try { + const response = await fetch( + `https://wttr.in/${encodeURIComponent(city)}?format=j1` + ); + const data = await response.json() as any; + const current = data.current_condition[0]; + return `🌤 ${city} 날씨 +온도: ${current.temp_C}°C (체감 ${current.FeelsLikeC}°C) +상태: ${current.weatherDesc[0].value} +습도: ${current.humidity}% +풍속: ${current.windspeedKmph} km/h`; + } catch (error) { + return `날씨 정보를 가져올 수 없습니다: ${city}`; + } + } + + case 'search_web': { + // 간단한 DuckDuckGo Instant Answer API + const query = args.query; + try { + const response = await fetch( + `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1` + ); + const data = await response.json() as any; + if (data.Abstract) { + return `🔍 검색 결과: ${query}\n\n${data.Abstract}\n\n출처: ${data.AbstractSource}`; + } else if (data.RelatedTopics?.length > 0) { + const topics = data.RelatedTopics.slice(0, 3) + .filter((t: any) => t.Text) + .map((t: any) => `• ${t.Text}`) + .join('\n'); + return `🔍 관련 정보: ${query}\n\n${topics}`; + } + return `"${query}"에 대한 즉시 답변을 찾을 수 없습니다. 더 구체적인 질문을 해주세요.`; + } catch (error) { + return `검색 중 오류가 발생했습니다.`; + } + } + + case 'get_current_time': { + const timezone = args.timezone || 'Asia/Seoul'; + try { + const now = new Date(); + const formatted = now.toLocaleString('ko-KR', { timeZone: timezone }); + return `🕐 현재 시간 (${timezone}): ${formatted}`; + } catch (error) { + return `시간 정보를 가져올 수 없습니다.`; + } + } + + case 'calculate': { + const expression = args.expression; + try { + // 안전한 수식 계산 (기본 연산만) + const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, ''); + const result = Function('"use strict"; return (' + sanitized + ')')(); + return `🔢 계산 결과: ${expression} = ${result}`; + } catch (error) { + return `계산할 수 없는 수식입니다: ${expression}`; + } + } + + default: + return `알 수 없는 도구: ${name}`; + } +} + +// OpenAI API 호출 +async function callOpenAI( + apiKey: string, + messages: OpenAIMessage[], + useTools: boolean = true +): Promise { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages, + tools: useTools ? tools : undefined, + tool_choice: useTools ? '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(); +} + +// 메인 응답 생성 함수 +export async function generateOpenAIResponse( + env: Env, + userMessage: string, + systemPrompt: string, + recentContext: { role: 'user' | 'assistant'; content: string }[] +): Promise { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY not configured'); + } + + const messages: OpenAIMessage[] = [ + { role: 'system', content: systemPrompt }, + ...recentContext.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + { role: 'user', content: userMessage }, + ]; + + // 첫 번째 호출 + let response = await callOpenAI(env.OPENAI_API_KEY, messages); + let assistantMessage = response.choices[0].message; + + // Function Calling 처리 (최대 3회 반복) + let iterations = 0; + while (assistantMessage.tool_calls && iterations < 3) { + iterations++; + + // 도구 호출 결과 수집 + const toolResults: OpenAIMessage[] = []; + for (const toolCall of assistantMessage.tool_calls) { + const args = JSON.parse(toolCall.function.arguments); + const result = await executeTool(toolCall.function.name, args); + toolResults.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: result, + }); + } + + // 대화에 추가 + messages.push({ + role: 'assistant', + content: assistantMessage.content, + tool_calls: assistantMessage.tool_calls, + }); + messages.push(...toolResults); + + // 다시 호출 + response = await callOpenAI(env.OPENAI_API_KEY, messages, false); + assistantMessage = response.choices[0].message; + } + + return assistantMessage.content || '응답을 생성할 수 없습니다.'; +} + +// 프로필 생성용 (도구 없이) +export async function generateProfileWithOpenAI( + env: Env, + prompt: string +): Promise { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY not configured'); + } + + const response = await callOpenAI( + env.OPENAI_API_KEY, + [{ role: 'user', content: prompt }], + false + ); + + return response.choices[0].message.content || '프로필 생성 실패'; +} diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..683d813 --- /dev/null +++ b/src/security.ts @@ -0,0 +1,159 @@ +import { Env, TelegramUpdate } from './types'; + +// Telegram 서버 IP 대역 (2024년 기준) +// https://core.telegram.org/bots/webhooks#the-short-version +const TELEGRAM_IP_RANGES = [ + '149.154.160.0/20', + '91.108.4.0/22', +]; + +// CIDR 범위 체크 유틸 +function ipInCIDR(ip: string, cidr: string): boolean { + const [range, bits] = cidr.split('/'); + const mask = ~(2 ** (32 - parseInt(bits)) - 1); + + const ipNum = ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0); + const rangeNum = range.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0); + + return (ipNum & mask) === (rangeNum & mask); +} + +// IP 화이트리스트 검증 +function isValidTelegramIP(ip: string): boolean { + return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range)); +} + +// Webhook Secret Token 검증 (Timing-safe comparison) +function isValidSecretToken(request: Request, expectedSecret: string): boolean { + const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token'); + + if (!secretHeader || !expectedSecret) { + return false; + } + + // Timing-safe comparison + if (secretHeader.length !== expectedSecret.length) { + return false; + } + + let result = 0; + for (let i = 0; i < secretHeader.length; i++) { + result |= secretHeader.charCodeAt(i) ^ expectedSecret.charCodeAt(i); + } + + return result === 0; +} + +// 요청 본문 검증 +function isValidRequestBody(body: unknown): body is TelegramUpdate { + return ( + body !== null && + typeof body === 'object' && + 'update_id' in body && + typeof (body as TelegramUpdate).update_id === 'number' + ); +} + +// 타임스탬프 검증 (리플레이 공격 방지) +function isRecentUpdate(message: TelegramUpdate['message']): boolean { + if (!message?.date) return true; // 메시지가 없으면 통과 + + const messageTime = message.date * 1000; // Unix timestamp to ms + const now = Date.now(); + const maxAge = 60 * 1000; // 60초 + + return now - messageTime < maxAge; +} + +export interface SecurityCheckResult { + valid: boolean; + error?: string; + update?: TelegramUpdate; +} + +// 통합 보안 검증 +export async function validateWebhookRequest( + request: Request, + env: Env +): Promise { + // 1. HTTP 메서드 검증 + if (request.method !== 'POST') { + return { valid: false, error: 'Method not allowed' }; + } + + // 2. Content-Type 검증 + const contentType = request.headers.get('Content-Type'); + if (!contentType?.includes('application/json')) { + return { valid: false, error: 'Invalid content type' }; + } + + // 3. Secret Token 검증 (필수) + if (env.WEBHOOK_SECRET) { + if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) { + console.error('Invalid webhook secret token'); + return { valid: false, error: 'Invalid secret token' }; + } + } else { + console.warn('WEBHOOK_SECRET not configured - skipping token validation'); + } + + // 4. IP 화이트리스트 검증 (선택적 - CF에서는 CF-Connecting-IP 사용) + const clientIP = request.headers.get('CF-Connecting-IP'); + if (clientIP && !isValidTelegramIP(clientIP)) { + // 경고만 로그 (Cloudflare 프록시 환경에서는 정확하지 않을 수 있음) + console.warn(`Request from non-Telegram IP: ${clientIP}`); + } + + // 5. 요청 본문 파싱 및 검증 + let body: unknown; + try { + body = await request.json(); + } catch { + return { valid: false, error: 'Invalid JSON body' }; + } + + if (!isValidRequestBody(body)) { + return { valid: false, error: 'Invalid request body structure' }; + } + + // 6. 타임스탬프 검증 (리플레이 공격 방지) + if (!isRecentUpdate(body.message)) { + return { valid: false, error: 'Message too old' }; + } + + return { valid: true, update: body }; +} + +// Rate Limiting +const rateLimitMap = new Map(); + +export function checkRateLimit( + userId: string, + maxRequests: number = 30, + windowMs: number = 60000 +): boolean { + const now = Date.now(); + const userLimit = rateLimitMap.get(userId); + + if (!userLimit || now > userLimit.resetAt) { + rateLimitMap.set(userId, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (userLimit.count >= maxRequests) { + return false; + } + + userLimit.count++; + return true; +} + +// Rate limit 정리 (메모리 관리) +export function cleanupRateLimits(): void { + const now = Date.now(); + for (const [key, value] of rateLimitMap.entries()) { + if (now > value.resetAt) { + rateLimitMap.delete(key); + } + } +} diff --git a/src/summary-service.ts b/src/summary-service.ts new file mode 100644 index 0000000..e9e4598 --- /dev/null +++ b/src/summary-service.ts @@ -0,0 +1,277 @@ +import { Env, BufferedMessage, Summary, ConversationContext } from './types'; + +// 설정값 가져오기 +const getConfig = (env: Env) => ({ + summaryThreshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10), + maxSummaries: parseInt(env.MAX_SUMMARIES_PER_USER || '3', 10), +}); + +// 버퍼에 메시지 추가 +export async function addToBuffer( + db: D1Database, + userId: number, + chatId: string, + role: 'user' | 'bot', + message: string +): Promise { + await db + .prepare(` + INSERT INTO message_buffer (user_id, chat_id, role, message) + VALUES (?, ?, ?, ?) + `) + .bind(userId, chatId, role, message) + .run(); + + const count = await db + .prepare('SELECT COUNT(*) as cnt FROM message_buffer WHERE user_id = ? AND chat_id = ?') + .bind(userId, chatId) + .first<{ cnt: number }>(); + + return count?.cnt || 0; +} + +// 버퍼 메시지 조회 +export async function getBufferedMessages( + db: D1Database, + userId: number, + chatId: string +): Promise { + const { results } = await db + .prepare(` + SELECT id, role, message, created_at + FROM message_buffer + WHERE user_id = ? AND chat_id = ? + ORDER BY created_at ASC + `) + .bind(userId, chatId) + .all(); + + return (results || []) as BufferedMessage[]; +} + +// 최신 요약 조회 +export async function getLatestSummary( + db: D1Database, + userId: number, + chatId: string +): Promise { + const summary = await db + .prepare(` + SELECT id, generation, summary, message_count, created_at + FROM summaries + WHERE user_id = ? AND chat_id = ? + ORDER BY generation DESC + LIMIT 1 + `) + .bind(userId, chatId) + .first(); + + return summary || null; +} + +// 전체 컨텍스트 조회 +export async function getConversationContext( + db: D1Database, + userId: number, + chatId: string +): Promise { + const [previousSummary, recentMessages] = await Promise.all([ + getLatestSummary(db, userId, chatId), + getBufferedMessages(db, userId, chatId), + ]); + + const totalMessages = (previousSummary?.message_count || 0) + recentMessages.length; + + return { + previousSummary, + recentMessages, + totalMessages, + }; +} + +// AI 요약 생성 +async function generateSummary( + env: Env, + previousSummary: string | null, + messages: BufferedMessage[] +): Promise { + // 사용자 메시지만 추출 + const userMessages = messages + .filter((m) => m.role === 'user') + .map((m) => `- ${m.message}`) + .join('\n'); + + // 사용자 메시지 수 + const userMsgCount = messages.filter((m) => m.role === 'user').length; + + let prompt: string; + + if (previousSummary) { + prompt = `당신은 사용자 프로필 분석 전문가입니다. +기존 사용자 프로필과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요. + +## 기존 사용자 프로필 +${previousSummary} + +## 새로운 사용자 발언 (${userMsgCount}개) +${userMessages} + +## 요구사항 +1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석 +2. **의미 있는 정보 추출**: + - 사용자의 관심사, 취미, 선호도 + - 질문한 주제들 (무엇에 대해 알고 싶어하는지) + - 요청사항, 목표, 해결하려는 문제 + - 개인적 맥락 (직업, 상황, 배경 등) + - 감정 상태나 태도 변화 +3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략 +4. **간결하게**: 300-400자 이내 +5. **한국어로 작성** + +업데이트된 사용자 프로필:`; + } else { + prompt = `당신은 사용자 프로필 분석 전문가입니다. +대화 내용에서 사용자에 대한 정보를 추출하여 프로필을 작성하세요. + +## 사용자 발언 (${userMsgCount}개) +${userMessages} + +## 요구사항 +1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석 +2. **의미 있는 정보 추출**: + - 사용자의 관심사, 취미, 선호도 + - 질문한 주제들 (무엇에 대해 알고 싶어하는지) + - 요청사항, 목표, 해결하려는 문제 + - 개인적 맥락 (직업, 상황, 배경 등) +3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략 +4. **간결하게**: 200-300자 이내 +5. **한국어로 작성** + +사용자 프로필:`; + } + + // OpenAI 사용 (설정된 경우) + if (env.OPENAI_API_KEY) { + const { generateProfileWithOpenAI } = await import('./openai-service'); + return generateProfileWithOpenAI(env, prompt); + } + + // 폴백: Workers AI + const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [{ role: 'user', content: prompt }], + max_tokens: 500, + }); + + return response.response || '프로필 생성 실패'; +} + +// 오래된 요약 정리 +async function cleanupOldSummaries( + db: D1Database, + userId: number, + chatId: string, + maxSummaries: number +): Promise { + await db + .prepare(` + DELETE FROM summaries + WHERE user_id = ? AND chat_id = ? + AND id NOT IN ( + SELECT id FROM summaries + WHERE user_id = ? AND chat_id = ? + ORDER BY generation DESC + LIMIT ? + ) + `) + .bind(userId, chatId, userId, chatId, maxSummaries) + .run(); +} + +// 요약 실행 및 저장 +export async function processAndSummarize( + env: Env, + userId: number, + chatId: string +): Promise<{ summarized: boolean; summary?: string }> { + const config = getConfig(env); + const messages = await getBufferedMessages(env.DB, userId, chatId); + + if (messages.length < config.summaryThreshold) { + return { summarized: false }; + } + + const previousSummary = await getLatestSummary(env.DB, userId, chatId); + + // AI 요약 생성 + const newSummary = await generateSummary( + env, + previousSummary?.summary || null, + messages + ); + + const newGeneration = (previousSummary?.generation || 0) + 1; + const newMessageCount = (previousSummary?.message_count || 0) + messages.length; + + // 트랜잭션 실행 + await env.DB.batch([ + env.DB + .prepare(` + INSERT INTO summaries (user_id, chat_id, generation, summary, message_count) + VALUES (?, ?, ?, ?, ?) + `) + .bind(userId, chatId, newGeneration, newSummary, newMessageCount), + + env.DB + .prepare('DELETE FROM message_buffer WHERE user_id = ? AND chat_id = ?') + .bind(userId, chatId), + ]); + + // 오래된 요약 정리 + await cleanupOldSummaries(env.DB, userId, chatId, config.maxSummaries); + + return { summarized: true, summary: newSummary }; +} + +// AI 응답 생성 (컨텍스트 포함) +export async function generateAIResponse( + env: Env, + userId: number, + chatId: string, + userMessage: string +): Promise { + const context = await getConversationContext(env.DB, userId, chatId); + + const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다. +${context.previousSummary ? ` +## 사용자 프로필 +${context.previousSummary.summary} + +위 프로필을 바탕으로 사용자의 관심사와 맥락을 이해하고 개인화된 응답을 제공하세요. +` : ''} +- 날씨, 시간, 계산, 검색 등의 요청은 제공된 도구를 사용하세요. +- 응답은 간결하고 도움이 되도록 한국어로 작성하세요.`; + + const recentContext = context.recentMessages.slice(-10).map((m) => ({ + role: m.role === 'user' ? 'user' as const : 'assistant' as const, + content: m.message, + })); + + // OpenAI 사용 (설정된 경우) + if (env.OPENAI_API_KEY) { + const { generateOpenAIResponse } = await import('./openai-service'); + return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext); + } + + // 폴백: Workers AI + const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [ + { role: 'system', content: systemPrompt }, + ...recentContext, + { role: 'user', content: userMessage }, + ], + max_tokens: 500, + }); + + return response.response || '응답을 생성할 수 없습니다.'; +} + diff --git a/src/telegram.ts b/src/telegram.ts new file mode 100644 index 0000000..dda626a --- /dev/null +++ b/src/telegram.ts @@ -0,0 +1,107 @@ +// Telegram API 메시지 전송 +export async function sendMessage( + token: string, + chatId: number, + text: string, + options?: { + parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2'; + reply_to_message_id?: number; + disable_notification?: boolean; + } +): Promise { + try { + const response = await fetch( + `https://api.telegram.org/bot${token}/sendMessage`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: options?.parse_mode || 'HTML', + reply_to_message_id: options?.reply_to_message_id, + disable_notification: options?.disable_notification, + }), + } + ); + + if (!response.ok) { + const error = await response.text(); + console.error('Telegram API error:', error); + return false; + } + + return true; + } catch (error) { + console.error('Failed to send message:', error); + return false; + } +} + +// Webhook 설정 (Secret Token 포함) +export async function setWebhook( + token: string, + webhookUrl: string, + secretToken: string +): Promise<{ ok: boolean; description?: string }> { + const response = await fetch( + `https://api.telegram.org/bot${token}/setWebhook`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: webhookUrl, + secret_token: secretToken, + allowed_updates: ['message'], + drop_pending_updates: true, + }), + } + ); + + return response.json(); +} + +// Webhook 정보 조회 +export async function getWebhookInfo( + token: string +): Promise { + const response = await fetch( + `https://api.telegram.org/bot${token}/getWebhookInfo` + ); + return response.json(); +} + +// Webhook 삭제 +export async function deleteWebhook( + token: string +): Promise<{ ok: boolean }> { + const response = await fetch( + `https://api.telegram.org/bot${token}/deleteWebhook`, + { method: 'POST' } + ); + return response.json(); +} + +// 타이핑 액션 전송 +export async function sendChatAction( + token: string, + chatId: number, + action: 'typing' | 'upload_photo' | 'upload_document' = 'typing' +): Promise { + try { + const response = await fetch( + `https://api.telegram.org/bot${token}/sendChatAction`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + action, + }), + } + ); + return response.ok; + } catch { + return false; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8dc48bc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,68 @@ +export interface Env { + DB: D1Database; + AI: Ai; + BOT_TOKEN: string; + WEBHOOK_SECRET: string; + SUMMARY_THRESHOLD?: string; + MAX_SUMMARIES_PER_USER?: string; + N8N_WEBHOOK_URL?: string; + OPENAI_API_KEY?: string; +} + +export interface IntentAnalysis { + action: 'chat' | 'n8n'; + type?: string; + confidence: number; +} + +export interface N8nResponse { + reply?: string; + error?: string; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; +} + +export interface TelegramMessage { + message_id: number; + from: TelegramUser; + chat: TelegramChat; + date: number; + text?: string; +} + +export interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; +} + +export interface TelegramChat { + id: number; + type: string; +} + +export interface BufferedMessage { + id: number; + role: 'user' | 'bot'; + message: string; + created_at: string; +} + +export interface Summary { + id: number; + generation: number; + summary: string; + message_count: number; + created_at: string; +} + +export interface ConversationContext { + previousSummary: Summary | null; + recentMessages: BufferedMessage[]; + totalMessages: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8bae1fd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2021"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..6a6c189 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,20 @@ +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" + +[[d1_databases]] +binding = "DB" +database_name = "telegram-conversations" +database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" + +# Secrets (wrangler secret put 으로 설정): +# - BOT_TOKEN: Telegram Bot Token +# - WEBHOOK_SECRET: Webhook 검증용 시크릿