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 <noreply@anthropic.com>
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -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/
|
||||||
327
README.md
Normal file
327
README.md
Normal file
@@ -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)
|
||||||
445
n8n-workflow-example.json
Normal file
445
n8n-workflow-example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
package.json
Normal file
27
package.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
42
schema.sql
Normal file
42
schema.sql
Normal file
@@ -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);
|
||||||
124
src/commands.ts
Normal file
124
src/commands.ts
Normal file
@@ -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<string> {
|
||||||
|
const config = {
|
||||||
|
threshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10),
|
||||||
|
maxSummaries: parseInt(env.MAX_SUMMARIES_PER_USER || '3', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case '/start':
|
||||||
|
return `👋 안녕하세요! AI 어시스턴트입니다.
|
||||||
|
|
||||||
|
<b>특징:</b>
|
||||||
|
• 대화를 통해 당신을 이해합니다
|
||||||
|
• ${config.threshold}개 메시지마다 프로필 업데이트
|
||||||
|
• 무한한 대화 기억 가능
|
||||||
|
|
||||||
|
<b>명령어:</b>
|
||||||
|
/help - 도움말
|
||||||
|
/context - 현재 컨텍스트 확인
|
||||||
|
/profile - 내 프로필 보기
|
||||||
|
/stats - 대화 통계
|
||||||
|
/reset - 대화 초기화`;
|
||||||
|
|
||||||
|
case '/help':
|
||||||
|
return `📖 <b>도움말</b>
|
||||||
|
|
||||||
|
<b>기본 명령어:</b>
|
||||||
|
/start - 봇 시작
|
||||||
|
/context - 현재 컨텍스트 상태
|
||||||
|
/profile - 내 프로필 보기
|
||||||
|
/stats - 대화 통계
|
||||||
|
/reset - 모든 대화 기록 삭제
|
||||||
|
|
||||||
|
<b>사용자 프로필 시스템:</b>
|
||||||
|
• 메시지 ${config.threshold}개마다 프로필 업데이트
|
||||||
|
• 사용자 발언 위주로 관심사/목표/맥락 분석
|
||||||
|
• 의미 없는 대화(인사, 확인 등)는 제외
|
||||||
|
|
||||||
|
일반 메시지를 보내면 AI가 응답합니다.`;
|
||||||
|
|
||||||
|
case '/context': {
|
||||||
|
const ctx = await getConversationContext(env.DB, userId, chatId);
|
||||||
|
const remaining = config.threshold - ctx.recentMessages.length;
|
||||||
|
|
||||||
|
return `📊 <b>현재 컨텍스트</b>
|
||||||
|
|
||||||
|
분석된 메시지: ${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 `👤 <b>내 프로필</b> (v${summary.generation})
|
||||||
|
|
||||||
|
${summary.summary}
|
||||||
|
|
||||||
|
<i>분석된 메시지: ${summary.message_count}개</i>
|
||||||
|
<i>업데이트: ${createdAt}</i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `📈 <b>대화 통계</b>
|
||||||
|
|
||||||
|
총 메시지: ${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 `🔧 <b>디버그 정보</b>
|
||||||
|
|
||||||
|
User ID: ${userId}
|
||||||
|
Chat ID: ${chatId}
|
||||||
|
Buffer Count: ${ctx.recentMessages.length}
|
||||||
|
Summary Gen: ${ctx.previousSummary?.generation || 0}
|
||||||
|
|
||||||
|
<b>최근 버퍼 (5개):</b>
|
||||||
|
${recentMsgs || '(비어있음)'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '❓ 알 수 없는 명령어입니다.\n/help를 입력해보세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/index.ts
Normal file
201
src/index.ts
Normal file
@@ -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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI Response error:', error);
|
||||||
|
responseText = `⚠️ AI 응답 생성 중 오류가 발생했습니다.\n\n<code>${String(error)}</code>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// HTTP 요청 핸들러
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
155
src/n8n-service.ts
Normal file
155
src/n8n-service.ts
Normal file
@@ -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<IntentAnalysis> {
|
||||||
|
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<N8nResponse> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
272
src/openai-service.ts
Normal file
272
src/openai-service.ts
Normal file
@@ -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<string, string>): Promise<string> {
|
||||||
|
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<OpenAIResponse> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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 || '프로필 생성 실패';
|
||||||
|
}
|
||||||
159
src/security.ts
Normal file
159
src/security.ts
Normal file
@@ -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<SecurityCheckResult> {
|
||||||
|
// 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<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/summary-service.ts
Normal file
277
src/summary-service.ts
Normal file
@@ -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<number> {
|
||||||
|
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<BufferedMessage[]> {
|
||||||
|
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<Summary | null> {
|
||||||
|
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<Summary>();
|
||||||
|
|
||||||
|
return summary || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 컨텍스트 조회
|
||||||
|
export async function getConversationContext(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
chatId: string
|
||||||
|
): Promise<ConversationContext> {
|
||||||
|
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<string> {
|
||||||
|
// 사용자 메시지만 추출
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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 || '응답을 생성할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
107
src/telegram.ts
Normal file
107
src/telegram.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<unknown> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/types.ts
Normal file
68
src/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
20
wrangler.toml
Normal file
20
wrangler.toml
Normal file
@@ -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 검증용 시크릿
|
||||||
Reference in New Issue
Block a user