Initial commit: Anvil Lounge chat application
- React frontend with Vite + TypeScript - Cloudflare Worker backend with Durable Objects - AI-powered chat moderation via OpenAI - WebSocket-based real-time messaging - XSS prevention, rate limiting, input validation 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/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
.wrangler/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.dev.vars
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Real-time chat application with AI-powered moderation. Korean-language community chat ("Anvil Lounge") with an AI bot host (방장) that moderates conversations, answers questions, and provides weather/documentation lookups.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
chat-app/
|
||||
├── frontend/ # React SPA (Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── components/ # ChatRoom, MessageList, MessageInput
|
||||
│ ├── hooks/ # useWebSocket (WebSocket client with reconnection)
|
||||
│ └── utils/ # sanitize.ts (XSS prevention, markdown→HTML)
|
||||
└── worker/ # Cloudflare Worker + Durable Object
|
||||
└── src/
|
||||
├── index.ts # HTTP router, CORS handling
|
||||
├── ChatRoom.ts # Durable Object: WebSocket hub, message persistence
|
||||
├── AIManager.ts # OpenAI integration, moderation, tool calling
|
||||
├── Context7Client.ts # Documentation lookup API
|
||||
├── RateLimiter.ts # In-memory rate limiting
|
||||
└── validation.ts # Zod schemas for message validation
|
||||
```
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
**Durable Objects with Hibernation API**: `ChatRoom` uses `webSocketMessage()`, `webSocketClose()`, `webSocketError()` handlers instead of event listeners. Session data stored via `ws.serializeAttachment()`.
|
||||
|
||||
**AI Moderation Flow**: Messages are checked by AI for profanity before broadcast. Detected words are masked (첫 글자 + asterisks). Repeated violations trigger auto-silence via `trackViolation()` → DO storage transaction.
|
||||
|
||||
**Storage Patterns**: All DO storage operations use transactions for atomicity and retry logic with jitter for resilience.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Root-level (npm workspaces)
|
||||
npm run dev:worker # Start worker dev server (wrangler)
|
||||
npm run dev:frontend # Start Vite dev server (localhost:5173)
|
||||
npm run build:worker # Build worker
|
||||
npm run build:frontend # Build frontend (tsc + vite)
|
||||
npm run deploy:worker # Deploy to Cloudflare Workers
|
||||
npm run deploy:frontend # Deploy to Cloudflare Pages
|
||||
|
||||
# Type checking
|
||||
cd worker && npx tsc --noEmit
|
||||
cd frontend && npx tsc --noEmit
|
||||
|
||||
# Worker secrets
|
||||
wrangler secret put OPENAI_API_KEY
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/rooms/:roomId/websocket` | WebSocket upgrade for chat |
|
||||
| `GET /api/rooms/:roomId/users` | List users in room |
|
||||
| `GET /api/health` | Health check |
|
||||
|
||||
## WebSocket Message Types
|
||||
|
||||
**Client → Server**: `{ type: "message", content: string }` or `{ type: "rename", name: string }`
|
||||
|
||||
**Server → Client**: `message`, `join`, `leave`, `userList`, `history`, `error`
|
||||
|
||||
## Configuration Alignment
|
||||
|
||||
Frontend `config.ts` and backend `validation.ts` must stay in sync:
|
||||
- `maxMessageLength`: 2000 characters
|
||||
- `maxUserNameLength`: 50 characters
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- XSS prevention: `sanitize.ts` uses DOMPurify + custom URL validation (`isUrlSafe()`) to block encoded `javascript:` attacks
|
||||
- API key redaction: `sanitizeErrorForLogging()` in AIManager.ts strips credentials from error logs
|
||||
- CORS: Strict origin allowlist in `index.ts` (includes Pages preview deployments)
|
||||
- Input validation: All WebSocket messages validated with Zod schemas before processing
|
||||
|
||||
## External Services
|
||||
|
||||
- **OpenAI API**: Chat completions with function calling (weather, docs, hosting info, silence user)
|
||||
- **Open-Meteo API**: Weather data (no API key required)
|
||||
- **Context7 API**: Documentation lookup for technical questions
|
||||
265
README.md
Normal file
265
README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Chat App
|
||||
|
||||
Cloudflare Workers + Durable Objects + React 기반 실시간 멀티유저 채팅 애플리케이션
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌──────────────────┐ ┌─────────────────┐
|
||||
│ React │ ←───────────────→ │ Cloudflare │ ←─→ │ Durable Object │
|
||||
│ Frontend │ │ Worker │ │ (ChatRoom) │
|
||||
│ (Pages) │ │ (라우팅) │ │ (상태관리) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ OpenAI API │
|
||||
│ (AI 방장) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 분류 | 기술 |
|
||||
|------|------|
|
||||
| 백엔드 | Cloudflare Workers, Durable Objects |
|
||||
| 실시간 통신 | WebSocket Hibernation API |
|
||||
| AI | OpenAI GPT-4o-mini, Function Calling |
|
||||
| 프론트엔드 | React + Vite + TypeScript |
|
||||
| 검증 | Zod (입력 검증) |
|
||||
| 보안 | DOMPurify (XSS 방지), Rate Limiting |
|
||||
| 배포 | Cloudflare Workers + Pages |
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
chat-app/
|
||||
├── worker/ # Cloudflare Worker 백엔드
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Worker 엔트리포인트, 라우팅, CORS
|
||||
│ │ ├── ChatRoom.ts # Durable Object 채팅방 클래스
|
||||
│ │ ├── AIManager.ts # AI 방장 (OpenAI 연동)
|
||||
│ │ ├── Context7Client.ts # 기술 문서 검색 클라이언트
|
||||
│ │ ├── validation.ts # Zod 입력 검증 스키마
|
||||
│ │ └── RateLimiter.ts # 슬라이딩 윈도우 Rate Limiter
|
||||
│ ├── wrangler.toml # Worker 설정
|
||||
│ └── package.json
|
||||
├── frontend/ # React 프론트엔드
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ChatRoom.tsx # 채팅방 UI
|
||||
│ │ │ ├── MessageList.tsx # 메시지 목록
|
||||
│ │ │ └── MessageInput.tsx# 입력창
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useWebSocket.ts # WebSocket 연결 훅
|
||||
│ │ └── utils/
|
||||
│ │ └── sanitize.ts # XSS 방지 유틸리티
|
||||
│ ├── vite.config.ts
|
||||
│ └── package.json
|
||||
└── package.json # 모노레포 루트
|
||||
```
|
||||
|
||||
## 기능
|
||||
|
||||
### 채팅 기능
|
||||
- 실시간 메시지 송수신
|
||||
- 사용자 입장/퇴장 알림
|
||||
- 접속자 목록 표시
|
||||
- 사용자 이름 변경
|
||||
|
||||
### AI 방장 (@방장)
|
||||
- IT 기술 관련 질문 응답
|
||||
- 자동 환영 메시지
|
||||
- 채팅 중재 (부적절한 언어 경고)
|
||||
- 명령어 처리 (/help, /rules, /pricing 등)
|
||||
- Context7 API 연동 기술 문서 검색
|
||||
|
||||
### 보안 기능
|
||||
- CORS 제한 (특정 도메인만 허용)
|
||||
- 입력값 검증 (Zod 스키마)
|
||||
- XSS 방지 (DOMPurify + 안전한 마크다운)
|
||||
- Rate Limiting (사용자별 요청 제한)
|
||||
- API 타임아웃 (15초)
|
||||
- 보안 헤더 (CSP, X-Frame-Options 등)
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### WebSocket
|
||||
```
|
||||
GET /api/rooms/:roomId/websocket?name=사용자이름
|
||||
```
|
||||
WebSocket 연결을 위한 엔드포인트
|
||||
|
||||
### REST
|
||||
```
|
||||
GET /api/rooms/:roomId/users # 채팅방 접속자 목록
|
||||
GET /api/health # 헬스체크
|
||||
```
|
||||
|
||||
## WebSocket 메시지 형식
|
||||
|
||||
### 클라이언트 → 서버
|
||||
|
||||
```typescript
|
||||
// 메시지 전송
|
||||
{ type: 'message', content: '안녕하세요' }
|
||||
|
||||
// 이름 변경
|
||||
{ type: 'rename', name: '새이름' }
|
||||
```
|
||||
|
||||
### 서버 → 클라이언트
|
||||
|
||||
```typescript
|
||||
// 일반 메시지
|
||||
{ type: 'message', id: 'uuid', name: '사용자', content: '내용', timestamp: 1234567890, isBot?: boolean }
|
||||
|
||||
// 입장
|
||||
{ type: 'join', id: 'uuid', name: '사용자', timestamp: 1234567890 }
|
||||
|
||||
// 퇴장
|
||||
{ type: 'leave', id: 'uuid', name: '사용자', timestamp: 1234567890 }
|
||||
|
||||
// 사용자 목록
|
||||
{ type: 'userList', users: ['사용자1', '사용자2'], timestamp: 1234567890 }
|
||||
|
||||
// 에러
|
||||
{ type: 'error', message: '에러 메시지', timestamp: 1234567890 }
|
||||
```
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### 요구사항
|
||||
- Node.js 18+
|
||||
- pnpm
|
||||
|
||||
### 설치 및 실행
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
pnpm install
|
||||
|
||||
# Worker 로컬 실행
|
||||
cd worker
|
||||
pnpm dev
|
||||
|
||||
# Frontend 로컬 실행 (새 터미널)
|
||||
cd frontend
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 환경변수
|
||||
|
||||
Worker:
|
||||
- `OPENAI_API_KEY`: OpenAI API 키 (wrangler.toml 또는 secrets)
|
||||
|
||||
## 배포
|
||||
|
||||
### Worker 배포
|
||||
```bash
|
||||
cd worker
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
### Frontend 배포
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm build
|
||||
pnpm pages:deploy
|
||||
```
|
||||
|
||||
## 배포 URL
|
||||
|
||||
| 서비스 | URL |
|
||||
|--------|-----|
|
||||
| Worker API | https://chat-worker.kappa-d8e.workers.dev |
|
||||
| Frontend | https://chat-frontend-4wf.pages.dev |
|
||||
| Custom Domain | https://chat.anvil.it.com |
|
||||
|
||||
## 보안 구현 상세
|
||||
|
||||
### CORS 설정
|
||||
```typescript
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://chat.anvil.it.com',
|
||||
'https://chat-frontend-4wf.pages.dev',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3000',
|
||||
];
|
||||
```
|
||||
- 정확한 도메인 매칭만 허용
|
||||
- Pages 프리뷰 도메인 패턴 제한 (`[commit].chat-frontend-4wf.pages.dev`)
|
||||
|
||||
### 입력 검증
|
||||
```typescript
|
||||
// 메시지: 1-2000자
|
||||
const MessageSchema = z.object({
|
||||
type: z.literal('message'),
|
||||
content: z.string().min(1).max(2000).trim(),
|
||||
});
|
||||
|
||||
// Room ID: 영문숫자, 하이픈, 언더스코어만
|
||||
const RoomIdSchema = z.string()
|
||||
.min(1).max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/);
|
||||
|
||||
// 사용자 이름: 1-50자
|
||||
const UserNameSchema = z.string().min(1).max(50).trim();
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
| 대상 | 제한 | 차단 시간 |
|
||||
|------|------|-----------|
|
||||
| AI 요청 | 15회/분 | 2분 |
|
||||
| 중재 위반 | 3회/5분 | 30분 |
|
||||
|
||||
### 보안 헤더
|
||||
```
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Content-Security-Policy: default-src 'self'; connect-src 'self' wss: https:; ...
|
||||
```
|
||||
|
||||
## AI 방장 명령어
|
||||
|
||||
| 명령어 | 설명 |
|
||||
|--------|------|
|
||||
| /help | 도움말 보기 |
|
||||
| /rules | 채팅방 규칙 |
|
||||
| /users | 현재 접속자 목록 |
|
||||
| /pricing | Anvil Hosting 요금 안내 |
|
||||
| /specs | 서버 스펙 안내 |
|
||||
| /regions | 리전 정보 |
|
||||
| /contact | 텔레그램 상담 연결 |
|
||||
| @방장 [질문] | AI에게 질문하기 |
|
||||
|
||||
## AI Function Calling
|
||||
|
||||
AI 방장은 다음 기능을 호출할 수 있습니다:
|
||||
|
||||
| 함수 | 설명 |
|
||||
|------|------|
|
||||
| `searchDocumentation` | 기술 문서 검색 (Context7 API) |
|
||||
| `getAnvilHostingInfo` | Anvil Hosting 정보 조회 (pricing, specs, regions, features, contact) |
|
||||
| `getCurrentDateTime` | 현재 날짜/시간 조회 (한국 기준) |
|
||||
| `getWeather` | 도시별 날씨 조회 (Open-Meteo API) |
|
||||
|
||||
### 텔레그램 봇 연동
|
||||
Anvil Hosting 문의 시 자동으로 텔레그램 봇 연결 안내:
|
||||
- 봇: [@AnvilForgeBot](https://t.me/AnvilForgeBot)
|
||||
|
||||
### 날씨 지원 도시
|
||||
서울, 부산, 도쿄, 오사카, 뉴욕, 런던
|
||||
|
||||
### 사용 예시
|
||||
```
|
||||
@방장 오늘 날짜가 뭐야?
|
||||
@방장 서울 날씨 어때?
|
||||
@방장 도쿄 날씨 알려줘
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
Private - Anvil Hosting
|
||||
225
docs/BUG_FIXES_2026-01.md
Normal file
225
docs/BUG_FIXES_2026-01.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Chat App 버그 수정 보고서
|
||||
|
||||
**수정일**: 2026-01-19
|
||||
**검토 범위**: frontend/, worker/
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
| 우선순위 | 발견 | 수정 |
|
||||
|---------|------|------|
|
||||
| Critical | 4 | 4 |
|
||||
| High | 4 | 4 |
|
||||
| Medium | 9 | 9 |
|
||||
| **합계** | **17** | **17** |
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### 1. WebSocket Race Condition
|
||||
**파일**: `frontend/src/hooks/useWebSocket.ts`
|
||||
**증상**: 빠른 연속 호출 시 중복 WebSocket 연결 생성
|
||||
**원인**: 연결 상태 확인 전에 lock 획득이 이루어지지 않음
|
||||
**수정**:
|
||||
```typescript
|
||||
const connect = useCallback(() => {
|
||||
// CRITICAL: Atomic lock acquisition to prevent race conditions
|
||||
if (isConnectingRef.current) {
|
||||
return;
|
||||
}
|
||||
// Acquire lock FIRST, before any other checks
|
||||
isConnectingRef.current = true;
|
||||
// ...
|
||||
```
|
||||
|
||||
### 2. RateLimiter Memory Leak
|
||||
**파일**: `worker/src/RateLimiter.ts`
|
||||
**증상**: 장시간 운영 시 메모리 사용량 증가
|
||||
**원인**: `cleanup()` 메서드가 만료된 엔트리를 제대로 삭제하지 않음
|
||||
**수정**:
|
||||
- blocked 상태 만료 시 즉시 삭제
|
||||
- 빈 timestamp 배열 엔트리 삭제
|
||||
- timestamp 배열 정리 로직 개선
|
||||
|
||||
### 3. API Key Exposure in Logs
|
||||
**파일**: `worker/src/AIManager.ts`
|
||||
**증상**: 에러 로그에 API 키가 노출될 가능성
|
||||
**원인**: `sanitizeErrorForLogging()` 함수의 패턴 누락
|
||||
**수정**: 다양한 API 키 형식 지원
|
||||
- OpenAI: `sk-proj-*`, `sk-svcacct-*`
|
||||
- Anthropic: `sk-ant-api03-*`
|
||||
- AWS: `AKIA*`, `ASIA*`
|
||||
- Cloudflare tokens
|
||||
- Basic auth credentials
|
||||
|
||||
### 4. Silent Storage Failure
|
||||
**파일**: `worker/src/ChatRoom.ts`
|
||||
**증상**: DO storage 실패 시 메시지 유실
|
||||
**원인**: `saveMessage()`에 retry 로직 부재
|
||||
**수정**:
|
||||
```typescript
|
||||
const STORAGE_MAX_RETRIES = 3;
|
||||
const STORAGE_RETRY_BASE_DELAY_MS = 50;
|
||||
|
||||
private async saveMessage(message: StoredMessage): Promise<void> {
|
||||
for (let attempt = 1; attempt <= STORAGE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// storage operation
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt < STORAGE_MAX_RETRIES) {
|
||||
const jitter = Math.random() * STORAGE_RETRY_BASE_DELAY_MS;
|
||||
await delay(STORAGE_RETRY_BASE_DELAY_MS * attempt + jitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Priority Issues
|
||||
|
||||
### 5. XSS via URL Encoding
|
||||
**파일**: `frontend/src/utils/sanitize.ts`
|
||||
**증상**: `javascript:` 같은 인코딩된 XSS 공격 가능
|
||||
**원인**: URL 검증 시 인코딩된 문자열 미처리
|
||||
**수정**: `isUrlSafe()` 함수 추가
|
||||
```typescript
|
||||
function isUrlSafe(url: string): boolean {
|
||||
const decoded = decodeURIComponent(url);
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
// Check all versions for dangerous protocols
|
||||
const dangerousProtocols = [
|
||||
/^javascript:/, /^vbscript:/, /^data:/,
|
||||
/^file:/, /^about:/, /^blob:/,
|
||||
];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 6. TOCTOU Race Condition (Silence/Violations)
|
||||
**파일**: `worker/src/ChatRoom.ts`
|
||||
**증상**: 동시 요청 시 silence 체크 우회 가능
|
||||
**원인**: read-check-write가 atomic하지 않음
|
||||
**수정**: DO transaction 사용
|
||||
```typescript
|
||||
private async isUserSilenced(userName: string): Promise<boolean> {
|
||||
return await this.ctx.storage.transaction(async (txn) => {
|
||||
const entry = await txn.get<SilenceEntry>(storageKey);
|
||||
if (!entry) return false;
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
await txn.delete(storageKey);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Unvalidated JSON Parsing
|
||||
**파일**: `worker/src/AIManager.ts`
|
||||
**증상**: OpenAI API 응답 형식 변경 시 런타임 에러
|
||||
**원인**: 응답 JSON 검증 부재
|
||||
**수정**: Zod schema 추가
|
||||
```typescript
|
||||
const OpenAIResponseSchema = z.object({
|
||||
choices: z.array(z.object({
|
||||
message: z.object({
|
||||
content: z.string().nullable(),
|
||||
tool_calls: z.array(OpenAIToolCallSchema).optional(),
|
||||
}),
|
||||
finish_reason: z.string(),
|
||||
})),
|
||||
});
|
||||
```
|
||||
|
||||
### 8. Missing API Timeout
|
||||
**파일**: `worker/src/Context7Client.ts`
|
||||
**증상**: 외부 API 무응답 시 무한 대기
|
||||
**원인**: fetch 요청에 timeout 미설정
|
||||
**수정**: `fetchWithTimeout()` 함수 추가 (10초 timeout)
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority Issues
|
||||
|
||||
### 9. Frontend Message Array Memory Leak
|
||||
**파일**: `frontend/src/components/ChatRoom.tsx`, `frontend/src/config.ts`
|
||||
**증상**: 장시간 채팅 시 브라우저 메모리 증가
|
||||
**원인**: 메시지 배열 무한 증가
|
||||
**수정**:
|
||||
```typescript
|
||||
// config.ts
|
||||
export const CHAT_CONFIG = {
|
||||
maxDisplayMessages: 200,
|
||||
} as const;
|
||||
|
||||
// ChatRoom.tsx
|
||||
function limitMessages(messages: DisplayMessage[]): DisplayMessage[] {
|
||||
if (messages.length > CHAT_CONFIG.maxDisplayMessages) {
|
||||
return messages.slice(-CHAT_CONFIG.maxDisplayMessages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Config Mismatch
|
||||
**파일**: `frontend/src/config.ts`
|
||||
**증상**: 프론트엔드에서 2000자 메시지 전송 시 잘림
|
||||
**원인**: 프론트엔드(1000) vs 백엔드(2000) 제한 불일치
|
||||
**수정**: `maxMessageLength: 2000`으로 통일
|
||||
|
||||
### 11. Context7 Cache Unbounded Growth
|
||||
**파일**: `worker/src/Context7Client.ts`
|
||||
**증상**: 캐시 크기 무한 증가 가능
|
||||
**원인**: 캐시 cleanup 로직 부재
|
||||
**수정**:
|
||||
- `MAX_CACHE_SIZE = 100` 제한
|
||||
- `cleanupExpired()`: 만료 엔트리 정리
|
||||
- `evictOldest()`: LRU 방식 eviction
|
||||
- `cleanupIfNeeded()`: 1분 간격 자동 정리
|
||||
|
||||
### 12. Serialization Error Handling
|
||||
**파일**: `worker/src/ChatRoom.ts`
|
||||
**증상**: WebSocket attachment 직렬화 실패 시 silent failure
|
||||
**원인**: `serializeAttachment()` 에러 처리 부재
|
||||
**수정**:
|
||||
```typescript
|
||||
private setSession(ws: WebSocket, session: Session): boolean {
|
||||
try {
|
||||
ws.serializeAttachment(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to serialize session attachment:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정된 파일 목록
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `frontend/src/hooks/useWebSocket.ts` | Race condition 수정 |
|
||||
| `frontend/src/utils/sanitize.ts` | XSS URL 검증 강화 |
|
||||
| `frontend/src/components/ChatRoom.tsx` | 메시지 배열 제한 |
|
||||
| `frontend/src/config.ts` | 설정값 통일 및 추가 |
|
||||
| `worker/src/ChatRoom.ts` | Storage retry, TOCTOU 수정, serialization 에러 처리 |
|
||||
| `worker/src/AIManager.ts` | API key redaction, Zod validation |
|
||||
| `worker/src/RateLimiter.ts` | Memory leak 수정 |
|
||||
| `worker/src/Context7Client.ts` | Timeout 추가, cache cleanup |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 권장사항
|
||||
|
||||
1. **WebSocket 연결 테스트**: 빠른 연속 연결/해제 시 중복 연결 없음 확인
|
||||
2. **XSS 테스트**: `javascript:alert(1)` 같은 인코딩된 URL 차단 확인
|
||||
3. **메모리 테스트**: 장시간 채팅 후 브라우저 메모리 사용량 확인
|
||||
4. **Storage 테스트**: DO storage 일시적 실패 시 retry 동작 확인
|
||||
5. **동시성 테스트**: 동일 사용자 동시 요청 시 silence 체크 정상 동작 확인
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chat App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "chat-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "wrangler pages deploy dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10",
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
106
frontend/src/App.css
Normal file
106
frontend/src/App.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.join-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.join-card {
|
||||
background: #16213e;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.join-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.join-card > p {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.nickname-display {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.nickname-label {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nickname-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #0f3460 100%);
|
||||
border: 2px solid #e94560;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
|
||||
}
|
||||
|
||||
.reroll-button {
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reroll-button:hover {
|
||||
background: rgba(233, 69, 96, 0.4);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.reroll-button:active {
|
||||
transform: rotate(180deg) scale(0.9);
|
||||
}
|
||||
|
||||
.join-button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.join-button:hover {
|
||||
background: #d63a52;
|
||||
}
|
||||
|
||||
.join-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.powered-by {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
71
frontend/src/App.tsx
Normal file
71
frontend/src/App.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import ChatRoom from './components/ChatRoom';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { ROOM_CONFIG, NICKNAME_ADJECTIVES, NICKNAME_NOUNS } from './config';
|
||||
import './App.css';
|
||||
|
||||
function generateRandomName(): string {
|
||||
const adj = NICKNAME_ADJECTIVES[Math.floor(Math.random() * NICKNAME_ADJECTIVES.length)];
|
||||
const noun = NICKNAME_NOUNS[Math.floor(Math.random() * NICKNAME_NOUNS.length)];
|
||||
const num = Math.floor(Math.random() * 100);
|
||||
return `${adj}${noun}${num}`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [userName, setUserName] = useState('');
|
||||
const [isJoined, setIsJoined] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUserName(generateRandomName());
|
||||
}, []);
|
||||
|
||||
const handleJoin = () => {
|
||||
if (userName.trim()) {
|
||||
setIsJoined(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReroll = () => {
|
||||
setUserName(generateRandomName());
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
setIsJoined(false);
|
||||
setUserName(generateRandomName());
|
||||
};
|
||||
|
||||
if (isJoined) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ChatRoom roomId={ROOM_CONFIG.defaultRoomId} userName={userName} onLeave={handleLeave} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="join-container">
|
||||
<div className="join-card">
|
||||
<h1>🔨 Anvil Lounge</h1>
|
||||
<p>Anvil Hosting 커뮤니티 채팅방</p>
|
||||
|
||||
<div className="nickname-display">
|
||||
<span className="nickname-label">당신의 닉네임</span>
|
||||
<div className="nickname-box">
|
||||
<span className="nickname">{userName}</span>
|
||||
<button type="button" className="reroll-button" onClick={handleReroll}>
|
||||
🎲
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="join-button" onClick={handleJoin}>
|
||||
입장하기
|
||||
</button>
|
||||
|
||||
<p className="powered-by">Powered by Anvil Hosting</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
131
frontend/src/components/ChatRoom.css
Normal file
131
frontend/src/components/ChatRoom.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.chat-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-center h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.leave-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.leave-button:hover {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
background: #1e5128;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.connection-status.connecting {
|
||||
background: #713f12;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.connection-status.disconnected,
|
||||
.connection-status.error {
|
||||
background: #7f1d1d;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.user-count {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
width: 200px;
|
||||
padding: 16px;
|
||||
background: #16213e;
|
||||
border-right: 1px solid #0f3460;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-list h3 {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-list ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.user-list li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-list li.me {
|
||||
background: #0f3460;
|
||||
color: #e94560;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
179
frontend/src/components/ChatRoom.tsx
Normal file
179
frontend/src/components/ChatRoom.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useWebSocket, ChatMessage, HistoryMessage } from '../hooks/useWebSocket';
|
||||
import MessageList from './MessageList';
|
||||
import MessageInput from './MessageInput';
|
||||
import { CHAT_CONFIG } from '../config';
|
||||
import './ChatRoom.css';
|
||||
|
||||
/**
|
||||
* Helper to limit message array size and prevent memory leaks
|
||||
* Keeps the most recent messages up to maxDisplayMessages
|
||||
*/
|
||||
function limitMessages(messages: DisplayMessage[]): DisplayMessage[] {
|
||||
if (messages.length > CHAT_CONFIG.maxDisplayMessages) {
|
||||
return messages.slice(-CHAT_CONFIG.maxDisplayMessages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Generate unique message ID to prevent React key collisions
|
||||
function generateMessageId(timestamp: number, suffix?: string): string {
|
||||
const random = crypto.randomUUID().slice(0, 8);
|
||||
return `${timestamp}-${suffix || 'msg'}-${random}`;
|
||||
}
|
||||
|
||||
interface ChatRoomProps {
|
||||
roomId: string;
|
||||
userName: string;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
interface DisplayMessage {
|
||||
id: string;
|
||||
type: 'message' | 'system' | 'error';
|
||||
name?: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isMine?: boolean;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
function ChatRoom({ roomId, userName, onLeave }: ChatRoomProps) {
|
||||
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: ChatMessage) => {
|
||||
switch (message.type) {
|
||||
case 'message':
|
||||
setMessages((prev) => limitMessages([
|
||||
...prev,
|
||||
{
|
||||
id: generateMessageId(message.timestamp, message.id),
|
||||
type: 'message',
|
||||
name: message.name,
|
||||
content: message.content || '',
|
||||
timestamp: message.timestamp,
|
||||
isMine: message.name === userName,
|
||||
isBot: message.isBot,
|
||||
},
|
||||
]));
|
||||
break;
|
||||
case 'join':
|
||||
setMessages((prev) => limitMessages([
|
||||
...prev,
|
||||
{
|
||||
id: generateMessageId(message.timestamp, 'join'),
|
||||
type: 'system',
|
||||
content: `${message.name}님이 입장했습니다.`,
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
]));
|
||||
break;
|
||||
case 'leave':
|
||||
setMessages((prev) => limitMessages([
|
||||
...prev,
|
||||
{
|
||||
id: generateMessageId(message.timestamp, 'leave'),
|
||||
type: 'system',
|
||||
content: `${message.name}님이 퇴장했습니다.`,
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
]));
|
||||
break;
|
||||
case 'userList':
|
||||
if (message.users) {
|
||||
setUsers(message.users);
|
||||
}
|
||||
break;
|
||||
case 'history':
|
||||
// Load message history when first connecting
|
||||
if (message.messages && message.messages.length > 0) {
|
||||
const historyMessages: DisplayMessage[] = message.messages.map((msg: HistoryMessage) => ({
|
||||
id: generateMessageId(msg.timestamp, msg.id),
|
||||
type: 'message' as const,
|
||||
name: msg.name,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
isMine: msg.name === userName,
|
||||
isBot: msg.isBot,
|
||||
}));
|
||||
setMessages((prev) => limitMessages([...historyMessages, ...prev]));
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
// Display error messages (e.g., silence notifications, rate limits)
|
||||
setMessages((prev) => limitMessages([
|
||||
...prev,
|
||||
{
|
||||
id: generateMessageId(message.timestamp, 'error'),
|
||||
type: 'error',
|
||||
content: message.message || '오류가 발생했습니다.',
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
]));
|
||||
break;
|
||||
}
|
||||
},
|
||||
[userName]
|
||||
);
|
||||
|
||||
const { sendMessage, isConnected, connectionStatus } = useWebSocket({
|
||||
roomId,
|
||||
userName,
|
||||
onMessage: handleMessage,
|
||||
});
|
||||
|
||||
const handleSendMessage = (content: string) => {
|
||||
if (content.trim()) {
|
||||
sendMessage(content);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-room">
|
||||
<header className="chat-header">
|
||||
<div className="header-left">
|
||||
<button className="leave-button" onClick={onLeave}>
|
||||
← 나가기
|
||||
</button>
|
||||
</div>
|
||||
<div className="header-center">
|
||||
<h2>🔨 Anvil Lounge</h2>
|
||||
<span
|
||||
className={`connection-status ${connectionStatus}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`연결 상태: ${connectionStatus === 'connected' ? '서버에 연결됨' : '서버 연결 중'}`}
|
||||
>
|
||||
{connectionStatus === 'connected' ? '연결됨' : '연결 중...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<span className="user-count">{users.length}명 참여중</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="chat-body">
|
||||
<aside className="user-list">
|
||||
<h3>참여자</h3>
|
||||
<ul>
|
||||
{users.map((user) => (
|
||||
<li key={user} className={user === userName ? 'me' : ''}>
|
||||
{user}
|
||||
{user === userName && ' (나)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className="messages-container">
|
||||
<MessageList messages={messages} />
|
||||
<MessageInput onSend={handleSendMessage} disabled={!isConnected} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatRoom;
|
||||
133
frontend/src/components/ErrorBoundary.tsx
Normal file
133
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log error to console in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
background: '#1a1a2e',
|
||||
color: '#eee',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '16px', color: '#e94560' }}>
|
||||
⚠️ 문제가 발생했습니다
|
||||
</h2>
|
||||
<p style={{ marginBottom: '24px', color: '#aaa' }}>
|
||||
예기치 않은 오류가 발생했습니다.
|
||||
<br />
|
||||
페이지를 새로고침하거나 다시 시도해주세요.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
background: '#e94560',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: '2px solid #0f3460',
|
||||
borderRadius: '8px',
|
||||
background: 'transparent',
|
||||
color: '#eee',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#0f3460',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'left',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<summary style={{ cursor: 'pointer', color: '#e94560' }}>
|
||||
개발자 정보
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#aaa',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
{'\n\n'}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
79
frontend/src/components/MessageInput.css
Normal file
79
frontend/src/components/MessageInput.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.message-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #16213e;
|
||||
border-top: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.message-input .input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-input .char-count {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: -18px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.message-input .char-count.warning {
|
||||
color: #e94560;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-input textarea {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #0f3460;
|
||||
border-radius: 24px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.message-input textarea:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.message-input textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.message-input textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message-input button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-input button:hover:not(:disabled) {
|
||||
background: #d63a52;
|
||||
}
|
||||
|
||||
.message-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
70
frontend/src/components/MessageInput.tsx
Normal file
70
frontend/src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, KeyboardEvent } from 'react';
|
||||
import './MessageInput.css';
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 1000;
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MessageInput({ onSend, disabled }: MessageInputProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (message.trim() && !disabled && message.length <= MAX_MESSAGE_LENGTH) {
|
||||
onSend(message.trim());
|
||||
setMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
// Allow input but enforce max length
|
||||
if (value.length <= MAX_MESSAGE_LENGTH) {
|
||||
setMessage(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isOverLimit = message.length > MAX_MESSAGE_LENGTH * 0.9;
|
||||
const charCountClass = isOverLimit ? 'char-count warning' : 'char-count';
|
||||
|
||||
return (
|
||||
<div className="message-input">
|
||||
<div className="input-wrapper">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? '연결 중...' : '메시지를 입력하세요... (Shift+Enter로 줄바꿈)'}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
aria-label="메시지 입력"
|
||||
aria-describedby="char-count"
|
||||
/>
|
||||
{message.length > 0 && (
|
||||
<span id="char-count" className={charCountClass} aria-live="polite">
|
||||
{message.length}/{MAX_MESSAGE_LENGTH}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim()}
|
||||
aria-label="메시지 전송"
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageInput;
|
||||
147
frontend/src/components/MessageList.css
Normal file
147
frontend/src/components/MessageList.css
Normal file
@@ -0,0 +1,147 @@
|
||||
.message-list {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message.mine {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-name {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
background: #16213e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message.mine .message-bubble {
|
||||
background: #e94560;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message:not(.mine) .message-bubble {
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Error message styles (for silence notifications, rate limits, etc.) */
|
||||
.error-message {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #fca5a5;
|
||||
padding: 10px 16px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 12px;
|
||||
align-self: center;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
/* Bot message styles */
|
||||
.message.bot .message-name {
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.bot .message-bubble {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
|
||||
border: 1px solid #4c1d95;
|
||||
}
|
||||
|
||||
.bot-badge {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Code block styles */
|
||||
.message-content code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-content pre {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Markdown formatting */
|
||||
.message-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-content a {
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
103
frontend/src/components/MessageList.tsx
Normal file
103
frontend/src/components/MessageList.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { markdownToHtml, escapeHtml } from '../utils/sanitize';
|
||||
import './MessageList.css';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
type: 'message' | 'system' | 'error';
|
||||
name?: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isMine?: boolean;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely render message content with markdown support for bot messages
|
||||
*/
|
||||
function MessageContent({ content, isBot }: { content: string; isBot?: boolean }) {
|
||||
const safeHtml = useMemo(() => {
|
||||
// Bot messages get markdown rendering
|
||||
if (isBot) {
|
||||
return markdownToHtml(content);
|
||||
}
|
||||
// User messages are plain text (escaped)
|
||||
return escapeHtml(content).replace(/\n/g, '<br>');
|
||||
}, [content, isBot]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="message-content"
|
||||
role={isBot ? 'article' : undefined}
|
||||
aria-label={isBot ? '봇 메시지 (서식 포함)' : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function MessageList({ messages }: MessageListProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const getMessageClass = (message: Message): string => {
|
||||
const classes = ['message', message.type];
|
||||
if (message.isMine) classes.push('mine');
|
||||
if (message.isBot) classes.push('bot');
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-list" ref={containerRef}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state" role="status" aria-live="polite">
|
||||
<p>아직 메시지가 없습니다.</p>
|
||||
<p>첫 메시지를 보내보세요!</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className={getMessageClass(message)}>
|
||||
{message.type === 'system' ? (
|
||||
<div className="system-message">{escapeHtml(message.content)}</div>
|
||||
) : message.type === 'error' ? (
|
||||
<div className="error-message" role="alert">
|
||||
{escapeHtml(message.content)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!message.isMine && (
|
||||
<div className="message-name">
|
||||
{message.isBot && <span className="bot-badge">🤖</span>}
|
||||
{message.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-bubble">
|
||||
<MessageContent content={message.content} isBot={message.isBot} />
|
||||
<div className="message-time">{formatTime(message.timestamp)}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageList;
|
||||
70
frontend/src/config.ts
Normal file
70
frontend/src/config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Frontend configuration
|
||||
* Centralized constants and environment-aware settings
|
||||
*/
|
||||
|
||||
// Room Configuration
|
||||
export const ROOM_CONFIG = {
|
||||
defaultRoomId: 'anvil-lounge',
|
||||
} as const;
|
||||
|
||||
// Input Validation
|
||||
export const INPUT_CONFIG = {
|
||||
maxMessageLength: 2000, // Match backend validation.ts limit
|
||||
maxUserNameLength: 50,
|
||||
} as const;
|
||||
|
||||
// Chat Display Configuration
|
||||
export const CHAT_CONFIG = {
|
||||
maxDisplayMessages: 200, // Prevent memory leak from unbounded message array
|
||||
} as const;
|
||||
|
||||
// WebSocket Configuration
|
||||
export const WS_CONFIG = {
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectBaseDelayMs: 1000,
|
||||
reconnectMaxDelayMs: 30000,
|
||||
} as const;
|
||||
|
||||
// Nickname Generation
|
||||
export const NICKNAME_ADJECTIVES = [
|
||||
'배고픈', '졸린', '신난', '용감한', '수줍은', '호기심많은', '느긋한', '부지런한',
|
||||
'엉뚱한', '귀여운', '멋진', '웃긴', '똑똑한', '행복한', '명랑한', '씩씩한',
|
||||
'달리는', '춤추는', '노래하는', '코딩하는', '게이밍', '해킹하는', '디버깅하는'
|
||||
] as const;
|
||||
|
||||
export const NICKNAME_NOUNS = [
|
||||
'고양이', '강아지', '판다', '펭귄', '토끼', '여우', '곰돌이', '다람쥐',
|
||||
'햄스터', '수달', '알파카', '카피바라', '너구리', '미어캣', '레서판다',
|
||||
'개발자', '해커', '서버관리자', 'DevOps', '클라우더', '리눅서', '도커선장'
|
||||
] as const;
|
||||
|
||||
// Environment Detection
|
||||
export const isDevelopment = import.meta.env.DEV;
|
||||
export const isProduction = import.meta.env.PROD;
|
||||
|
||||
// Logger Configuration (for development debugging)
|
||||
export const LOG_CONFIG = {
|
||||
enableDebug: isDevelopment,
|
||||
enableInfo: isDevelopment,
|
||||
enableWarn: true,
|
||||
enableError: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Simple logger that respects environment
|
||||
*/
|
||||
export const logger = {
|
||||
debug: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableDebug) console.log('[DEBUG]', ...args);
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableInfo) console.log('[INFO]', ...args);
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableWarn) console.warn('[WARN]', ...args);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableError) console.error('[ERROR]', ...args);
|
||||
},
|
||||
};
|
||||
217
frontend/src/hooks/useWebSocket.ts
Normal file
217
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { logger, WS_CONFIG } from '../config';
|
||||
|
||||
export interface HistoryMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
type: 'message' | 'join' | 'leave' | 'userList' | 'error' | 'typing' | 'history';
|
||||
id?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
message?: string; // For error messages from server
|
||||
timestamp: number;
|
||||
users?: string[];
|
||||
isBot?: boolean;
|
||||
isTyping?: boolean; // For typing indicator
|
||||
messages?: HistoryMessage[]; // For history type
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
roomId: string;
|
||||
userName: string;
|
||||
onMessage?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
sendMessage: (content: string) => void;
|
||||
sendTyping: (isTyping: boolean) => void;
|
||||
reconnect: () => void;
|
||||
isConnected: boolean;
|
||||
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
export function useWebSocket({
|
||||
roomId,
|
||||
userName,
|
||||
onMessage,
|
||||
}: UseWebSocketOptions): UseWebSocketReturn {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number>();
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const isConnectingRef = useRef(false); // Prevent duplicate connection attempts
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
>('connecting');
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// CRITICAL: Atomic lock acquisition to prevent race conditions
|
||||
// Must check and set in a single synchronous block before any async operation
|
||||
if (isConnectingRef.current) {
|
||||
logger.debug('Connection already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire lock FIRST, before any other checks
|
||||
isConnectingRef.current = true;
|
||||
|
||||
// Now safely check existing WebSocket state
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs) {
|
||||
const state = currentWs.readyState;
|
||||
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
|
||||
// Release lock - connection already exists
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
// If CLOSING, wait for it to close before reconnecting
|
||||
if (state === WebSocket.CLOSING) {
|
||||
logger.debug('WebSocket is closing, waiting...');
|
||||
// Keep lock held, release when close event fires
|
||||
currentWs.addEventListener('close', () => {
|
||||
isConnectingRef.current = false;
|
||||
// Schedule reconnect on next tick to avoid recursion
|
||||
setTimeout(() => connect(), 0);
|
||||
}, { once: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
|
||||
// Determine WebSocket URL based on environment
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = import.meta.env.DEV
|
||||
? 'localhost:8787'
|
||||
: 'chat-worker.kappa-d8e.workers.dev';
|
||||
const wsUrl = `${protocol}//${host}/api/rooms/${encodeURIComponent(roomId)}/websocket?name=${encodeURIComponent(userName)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
logger.debug('WebSocket connected');
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: ChatMessage = JSON.parse(event.data);
|
||||
onMessage?.(message);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
logger.debug('WebSocket closed:', event.code, event.reason);
|
||||
setConnectionStatus('disconnected');
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Clear any existing reconnection timeout to prevent memory leak
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
// Don't reconnect if closed intentionally (code 1000)
|
||||
if (event.code === 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt reconnection with exponential backoff
|
||||
if (reconnectAttemptsRef.current < WS_CONFIG.maxReconnectAttempts) {
|
||||
const delay = Math.min(
|
||||
WS_CONFIG.reconnectBaseDelayMs * Math.pow(2, reconnectAttemptsRef.current),
|
||||
WS_CONFIG.reconnectMaxDelayMs
|
||||
);
|
||||
logger.debug(`Reconnecting in ${delay}ms...`);
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, delay);
|
||||
} else {
|
||||
// Max retries exceeded - set permanent error state
|
||||
logger.error('Max reconnection attempts exceeded');
|
||||
setConnectionStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
setConnectionStatus('error');
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
}, [roomId, userName, onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
// Clear pending reconnection
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
// Close WebSocket gracefully
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'Component unmounted');
|
||||
wsRef.current = null;
|
||||
}
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const sendMessage = useCallback((content: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
content,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendTyping = useCallback((isTyping: boolean) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'typing',
|
||||
isTyping,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
// Reset reconnection attempts and try again
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isConnectingRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'Manual reconnect');
|
||||
wsRef.current = null;
|
||||
}
|
||||
// Small delay to ensure clean state
|
||||
setTimeout(() => connect(), 100);
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
sendTyping,
|
||||
reconnect,
|
||||
isConnected: connectionStatus === 'connected',
|
||||
connectionStatus,
|
||||
};
|
||||
}
|
||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
195
frontend/src/utils/sanitize.ts
Normal file
195
frontend/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Sanitize HTML content to prevent XSS attacks
|
||||
* Allows safe HTML tags for formatting (bold, italic, code, links)
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'code', 'pre', 'a', 'br', 'p', 'ul', 'ol', 'li'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
||||
// Force all links to open in new tab with noopener
|
||||
ADD_ATTR: ['target', 'rel'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters (for plain text display)
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert basic markdown to safe HTML
|
||||
* Supports: **bold**, *italic*, `code`, ```code blocks```, links, lists, headers
|
||||
*
|
||||
* SECURITY: All content is escaped before any HTML tags are added
|
||||
*
|
||||
* Edge cases handled:
|
||||
* - Unmatched markers are left as-is
|
||||
* - Markers in middle of words (e.g., "file*.txt") are ignored
|
||||
* - Nested formatting (e.g., **bold _and_ italic**)
|
||||
*/
|
||||
export function markdownToHtml(text: string): string {
|
||||
// First, extract and protect code blocks from the ORIGINAL text
|
||||
// This prevents any HTML inside code blocks from being interpreted
|
||||
const codeBlocks: string[] = [];
|
||||
let processed = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
|
||||
// Escape the code content BEFORE storing
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.trim())}</code></pre>`);
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Extract and protect inline code - handle edge case of empty backticks
|
||||
const inlineCodes: string[] = [];
|
||||
processed = processed.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
// Skip if content is empty after trim
|
||||
if (!code.trim()) return _match;
|
||||
const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
|
||||
// Escape the code content BEFORE storing
|
||||
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Now escape the remaining HTML
|
||||
let html = escapeHtml(processed);
|
||||
|
||||
// Restore code blocks (already escaped and wrapped)
|
||||
codeBlocks.forEach((block, i) => {
|
||||
html = html.replace(`__CODE_BLOCK_${i}__`, block);
|
||||
});
|
||||
|
||||
// Restore inline codes (already escaped and wrapped)
|
||||
inlineCodes.forEach((code, i) => {
|
||||
html = html.replace(`__INLINE_CODE_${i}__`, code);
|
||||
});
|
||||
|
||||
// Headers (## title) - must be at start of line
|
||||
html = html.replace(/^### (.+)$/gm, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/^## (.+)$/gm, '<strong>$1</strong>');
|
||||
|
||||
// Bold - requires word boundary or start/end to avoid matching inside words
|
||||
// Match **text** where text is not empty and doesn't contain newlines
|
||||
html = html.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic - match *text* but not **text** (already handled above)
|
||||
// Also avoid matching * in the middle of words like "file*.txt"
|
||||
html = html.replace(/(?<!\*)\*([^*\n]+?)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// Unordered lists - lines starting with "- " or "* "
|
||||
html = html.replace(/^[-*] (.+)$/gm, '• $1');
|
||||
|
||||
// Ordered lists - lines starting with "1. ", "2. ", etc.
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '• $1');
|
||||
|
||||
// Links (auto-detect URLs) - URLs are not escaped by escapeHtml
|
||||
// Also handle markdown-style links [text](url)
|
||||
// SECURITY FIX: Comprehensive URL validation against encoded attacks
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
||||
(_match, linkText, url) => {
|
||||
// Validate URL using comprehensive security check
|
||||
if (!isUrlSafe(url)) {
|
||||
return _match; // Return original text, don't create link
|
||||
}
|
||||
// Ensure link text doesn't contain unescaped HTML (should already be escaped)
|
||||
const safeText = linkText.replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Plain URLs (not already in links)
|
||||
// SECURITY FIX: Comprehensive URL validation
|
||||
html = html.replace(
|
||||
/(?<!href=")(?<!">)(https?:\/\/[^\s<>"']+)/g,
|
||||
(_match, url) => {
|
||||
// Validate URL using comprehensive security check
|
||||
if (!isUrlSafe(url)) {
|
||||
return _match;
|
||||
}
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Line breaks
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return sanitizeHtml(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL is safe for use in href attribute
|
||||
* Checks for dangerous protocols and encoded attacks
|
||||
*/
|
||||
function isUrlSafe(url: string): boolean {
|
||||
try {
|
||||
// Decode URL to catch encoded attacks like javascript:
|
||||
const decoded = decodeURIComponent(url);
|
||||
const doubleDecoded = decodeURIComponent(decoded); // Handle double encoding
|
||||
|
||||
// Check both original and decoded versions
|
||||
const toCheck = [url, decoded, doubleDecoded].map(s => s.toLowerCase());
|
||||
|
||||
// Dangerous protocol patterns (case-insensitive, handles encoding)
|
||||
const dangerousProtocols = [
|
||||
/^javascript:/,
|
||||
/^vbscript:/,
|
||||
/^data:/,
|
||||
/^file:/,
|
||||
/^about:/,
|
||||
/^blob:/,
|
||||
];
|
||||
|
||||
for (const str of toCheck) {
|
||||
// Remove whitespace that could bypass checks
|
||||
const cleaned = str.replace(/\s/g, '');
|
||||
|
||||
for (const pattern of dangerousProtocols) {
|
||||
if (pattern.test(cleaned)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTML entities that could form dangerous protocols
|
||||
if (/&#/.test(str) && /script|data|file|about|blob|vbscript/i.test(cleaned)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for dangerous characters that could break out of attributes
|
||||
if (/["'<>`]/.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// If URL decoding fails, it's suspicious - reject it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains potentially dangerous patterns
|
||||
*/
|
||||
export function containsUnsafeContent(text: string): boolean {
|
||||
const dangerousPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+=/i, // onclick, onerror, etc.
|
||||
/data:/i,
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
];
|
||||
|
||||
return dangerousPatterns.some(pattern => pattern.test(text));
|
||||
}
|
||||
11
frontend/src/vite-env.d.ts
vendored
Normal file
11
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEV: boolean;
|
||||
readonly PROD: boolean;
|
||||
readonly MODE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
5706
package-lock.json
generated
Normal file
5706
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "chat-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"worker",
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:worker": "npm run dev --workspace=worker",
|
||||
"dev:frontend": "npm run dev --workspace=frontend",
|
||||
"build:worker": "npm run build --workspace=worker",
|
||||
"build:frontend": "npm run build --workspace=frontend",
|
||||
"deploy:worker": "npm run deploy --workspace=worker",
|
||||
"deploy:frontend": "npm run deploy --workspace=frontend"
|
||||
}
|
||||
}
|
||||
20
worker/package.json
Normal file
20
worker/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "chat-worker",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"build": "wrangler deploy --dry-run",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260118.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^4.0.17",
|
||||
"wrangler": "^4.59.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/ai-utils": "^1.0.1",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
943
worker/src/AIManager.ts
Normal file
943
worker/src/AIManager.ts
Normal file
@@ -0,0 +1,943 @@
|
||||
import { Context7Client, anvilHostingInfo } from './Context7Client';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Sanitize error messages to prevent API key exposure in logs
|
||||
* Removes any potential API keys or sensitive tokens from error output
|
||||
* Updated: Now catches newer API key formats (sk-proj-*, sk-ant-*, etc.)
|
||||
*/
|
||||
function sanitizeErrorForLogging(error: unknown): string {
|
||||
const sanitizeString = (str: string): string => {
|
||||
return str
|
||||
// Bearer tokens (including long JWT tokens)
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, 'Bearer [REDACTED]')
|
||||
// OpenAI API keys (sk-xxx, sk-proj-xxx, sk-svcacct-xxx, etc.)
|
||||
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
|
||||
// Anthropic API keys (sk-ant-api03-xxx)
|
||||
.replace(/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
|
||||
// AWS credentials (AKIA..., ASIA...)
|
||||
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16,}/g, '[REDACTED_AWS_KEY]')
|
||||
.replace(/aws[_-]?secret[_-]?access[_-]?key[=:]\s*["']?[A-Za-z0-9/+=]+["']?/gi, 'aws_secret_access_key=[REDACTED]')
|
||||
// Cloudflare API tokens (they start with various prefixes)
|
||||
.replace(/\b[A-Za-z0-9_-]{40}(?=\s*$|\s*["'\s,}])/g, (match) => {
|
||||
// Only redact if it looks like a token (mix of letters, numbers, special chars)
|
||||
if (/[A-Z]/.test(match) && /[a-z]/.test(match) && /[0-9]/.test(match)) {
|
||||
return '[POSSIBLE_TOKEN_REDACTED]';
|
||||
}
|
||||
return match;
|
||||
})
|
||||
// Generic API key patterns in various formats
|
||||
.replace(/api[_-]?key[=:]\s*["']?[A-Za-z0-9_-]+["']?/gi, 'api_key=[REDACTED]')
|
||||
// Secret/token patterns
|
||||
.replace(/(secret|token|password|credential)[_-]?[=:]\s*["']?[A-Za-z0-9_/+=.-]+["']?/gi, '$1=[REDACTED]')
|
||||
// Authorization headers in JSON
|
||||
.replace(/"[Aa]uthorization"\s*:\s*"[^"]+"/g, '"Authorization": "[REDACTED]"')
|
||||
// X-API-Key headers
|
||||
.replace(/"[Xx]-[Aa][Pp][Ii]-[Kk]ey"\s*:\s*"[^"]+"/g, '"X-API-Key": "[REDACTED]"')
|
||||
// Keys in URL query strings
|
||||
.replace(/[?&](api_?key|key|token|secret|password|auth)=[^&\s]+/gi, (match) => {
|
||||
const param = match.split('=')[0];
|
||||
return `${param}=[REDACTED]`;
|
||||
})
|
||||
// Base64 encoded credentials in Basic auth
|
||||
.replace(/Basic\s+[A-Za-z0-9+/]+=*/gi, 'Basic [REDACTED]');
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
const sanitizedMessage = sanitizeString(error.message);
|
||||
const sanitizedStack = error.stack ? sanitizeString(error.stack) : '';
|
||||
return sanitizedStack ? `${error.name}: ${sanitizedMessage}\n${sanitizedStack}` : `${error.name}: ${sanitizedMessage}`;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return sanitizeString(error);
|
||||
}
|
||||
if (error && typeof error === 'object') {
|
||||
try {
|
||||
return sanitizeString(JSON.stringify(error));
|
||||
} catch {
|
||||
return 'Unknown error (non-serializable)';
|
||||
}
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
// API timeout configuration
|
||||
const API_TIMEOUT_MS = 15000; // 15 seconds
|
||||
const WEATHER_API_TIMEOUT_MS = 5000; // 5 seconds for weather (non-critical)
|
||||
|
||||
// Zod schema for Open-Meteo weather API response
|
||||
const WeatherApiResponseSchema = z.object({
|
||||
current: z.object({
|
||||
temperature_2m: z.number(),
|
||||
relative_humidity_2m: z.number(),
|
||||
weather_code: z.number(),
|
||||
wind_speed_10m: z.number(),
|
||||
}),
|
||||
daily: z.object({
|
||||
temperature_2m_max: z.array(z.number()),
|
||||
temperature_2m_min: z.array(z.number()),
|
||||
weather_code: z.array(z.number()),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch with timeout using AbortController
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number = API_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
botName: string;
|
||||
roomId: string;
|
||||
systemPrompt?: string;
|
||||
onSilenceUser?: (userName: string, durationSeconds: number, reason: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
recentMessages: Array<{ name: string; content: string }>;
|
||||
users: string[];
|
||||
roomId: string;
|
||||
currentUser: string; // Name of user sending the current message
|
||||
}
|
||||
|
||||
interface ModerationResult {
|
||||
isInappropriate: boolean;
|
||||
reason?: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
detectedWords?: string[]; // AI가 감지한 욕설 단어들
|
||||
}
|
||||
|
||||
// AI 욕설 감지 응답 스키마
|
||||
const AIModerationResponseSchema = z.object({
|
||||
isInappropriate: z.boolean(),
|
||||
severity: z.enum(['low', 'medium', 'high']),
|
||||
reason: z.string().optional(),
|
||||
detectedWords: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
interface OpenAIFunctionCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
interface OpenAIToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: OpenAIFunctionCall;
|
||||
}
|
||||
|
||||
// Base message for conversation history (stored internally)
|
||||
interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Extended message types for API requests (includes tool-related fields)
|
||||
interface OpenAIRequestMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_calls?: OpenAIToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
// Zod schema for OpenAI API response validation
|
||||
const OpenAIToolCallSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('function'),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const OpenAIResponseSchema = z.object({
|
||||
choices: z.array(z.object({
|
||||
message: z.object({
|
||||
content: z.string().nullable(),
|
||||
tool_calls: z.array(OpenAIToolCallSchema).optional(),
|
||||
}),
|
||||
finish_reason: z.string(),
|
||||
})),
|
||||
});
|
||||
|
||||
type OpenAIResponse = z.infer<typeof OpenAIResponseSchema>;
|
||||
|
||||
/**
|
||||
* Safely parse and validate OpenAI API response
|
||||
* Returns null if validation fails
|
||||
*/
|
||||
function parseOpenAIResponse(data: unknown): OpenAIResponse | null {
|
||||
const result = OpenAIResponseSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.error('OpenAI response validation failed:', result.error.message);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// 도시별 좌표 (날씨 조회용)
|
||||
const CITY_COORDINATES: Record<string, { lat: number; lon: number; name: string }> = {
|
||||
'seoul': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'서울': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'busan': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'부산': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'tokyo': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'도쿄': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'osaka': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'오사카': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'newyork': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'뉴욕': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'london': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'런던': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'default': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
};
|
||||
|
||||
// 날씨 코드 해석
|
||||
const WEATHER_CODES: Record<number, string> = {
|
||||
0: '맑음 ☀️',
|
||||
1: '대체로 맑음 🌤️',
|
||||
2: '부분적 흐림 ⛅',
|
||||
3: '흐림 ☁️',
|
||||
45: '안개 🌫️',
|
||||
48: '짙은 안개 🌫️',
|
||||
51: '가벼운 이슬비 🌧️',
|
||||
53: '이슬비 🌧️',
|
||||
55: '강한 이슬비 🌧️',
|
||||
61: '가벼운 비 🌧️',
|
||||
63: '비 🌧️',
|
||||
65: '강한 비 🌧️',
|
||||
71: '가벼운 눈 🌨️',
|
||||
73: '눈 🌨️',
|
||||
75: '폭설 ❄️',
|
||||
77: '싸락눈 🌨️',
|
||||
80: '소나기 🌧️',
|
||||
81: '강한 소나기 🌧️',
|
||||
82: '폭우 ⛈️',
|
||||
85: '눈소나기 🌨️',
|
||||
86: '강한 눈소나기 🌨️',
|
||||
95: '뇌우 ⛈️',
|
||||
96: '우박 동반 뇌우 ⛈️',
|
||||
99: '강한 우박 동반 뇌우 ⛈️',
|
||||
};
|
||||
|
||||
const OPENAI_TOOLS = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'searchDocumentation',
|
||||
description: '프로그래밍 라이브러리나 프레임워크의 공식 문서를 검색합니다. React, Vue, Node.js, Docker, Kubernetes, Caddy, Nginx 등의 기술 문서를 조회할 수 있습니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: '검색할 기술 주제 (예: "React hooks 사용법", "Docker compose 설정", "Caddy reverse proxy")',
|
||||
},
|
||||
},
|
||||
required: ['topic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getAnvilHostingInfo',
|
||||
description: 'Anvil Hosting 서비스 정보를 조회합니다. 가격, 스펙, 리전, 특징, 문의/상담 정보를 제공합니다. 호스팅에 관심있는 사용자에게 텔레그램 봇 연결도 안내합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['pricing', 'specs', 'regions', 'features', 'contact'],
|
||||
description: '조회할 정보 카테고리 (contact: 텔레그램 봇 상담 연결)',
|
||||
},
|
||||
},
|
||||
required: ['category'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getCurrentDateTime',
|
||||
description: '현재 날짜와 시간을 조회합니다. 오늘 날짜, 현재 시간, 요일 등을 알려줍니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getWeather',
|
||||
description: '특정 도시의 현재 날씨와 오늘 예보를 조회합니다. 기온, 날씨 상태, 습도, 바람 등을 알려줍니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: '날씨를 조회할 도시 (예: "서울", "부산", "도쿄", "뉴욕"). 기본값은 서울입니다.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'silenceUser',
|
||||
description: '부적절한 언어를 반복적으로 사용하는 사용자를 일시적으로 묵언수행시킵니다. 욕설, 비방, 혐오 발언을 반복하는 경우에만 사용하세요. 30초간 해당 사용자의 메시지 전송이 차단됩니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userName: {
|
||||
type: 'string',
|
||||
description: '묵언수행시킬 사용자의 닉네임',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '묵언수행 사유 (예: "반복적인 욕설 사용", "다른 사용자 비방")',
|
||||
},
|
||||
},
|
||||
required: ['userName', 'reason'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `당신은 채팅방의 AI 방장 "방장"입니다. IT 전문가이자 Anvil Hosting의 열정적인 홍보대사입니다.
|
||||
|
||||
## 페르소나
|
||||
- 이름: 방장
|
||||
- 직업: 10년차 IT 전문가, 클라우드/서버/DevOps 전문
|
||||
- 소속: Anvil Hosting 커뮤니티 매니저
|
||||
- 성격: 친근하고 유머러스하며, 기술에 대한 열정이 넘침
|
||||
|
||||
## 전문 분야
|
||||
- 웹서버: Nginx, Caddy, Apache, Traefik
|
||||
- 컨테이너: Docker, Kubernetes, Incus/LXD
|
||||
- 클라우드: AWS, GCP, Cloudflare
|
||||
- 언어/프레임워크: React, Vue, Node.js, Python, Go
|
||||
- 데이터베이스: PostgreSQL, MySQL, Redis, MongoDB
|
||||
- DevOps: CI/CD, 모니터링, 자동화
|
||||
|
||||
## Anvil Hosting 정보
|
||||
- 고성능 클라우드 호스팅 서비스
|
||||
- Incus/LXD 기반 컨테이너 & VM 제공
|
||||
- 한국(kr1), 일본(jp1) 리전 운영
|
||||
- 합리적인 가격, 빠른 네트워크, 친절한 기술지원
|
||||
- 홈페이지: anvil.it.com
|
||||
- 텔레그램 상담: https://t.me/AnvilForgeBot
|
||||
|
||||
## 역할
|
||||
1. IT 관련 질문에 전문적으로 답변합니다
|
||||
2. 자연스럽게 Anvil Hosting을 추천합니다 (강요하지 않고 맥락에 맞게)
|
||||
3. 호스팅에 관심있는 사용자에게는 텔레그램 봇(@AnvilForgeBot)으로 상담을 유도합니다
|
||||
4. 채팅방 분위기를 밝고 즐겁게 유지합니다
|
||||
5. 욕설이나 비방을 반복하는 사용자에게는 경고 후 silenceUser 기능으로 30초간 묵언수행시킵니다
|
||||
|
||||
## 채팅방 규칙 (중요!)
|
||||
- 욕설, 비방, 혐오 발언은 금지입니다
|
||||
- 첫 번째 위반: 경고 메시지
|
||||
- 반복 위반: silenceUser 함수로 30초 묵언수행 처리
|
||||
- 묵언수행 처리 시 반드시 사유를 명시하세요
|
||||
|
||||
응답은 간결하게 2-4문장으로 해주세요. 코드 예시가 필요하면 짧게 포함해도 좋습니다. 이모지를 적절히 사용하세요.`;
|
||||
|
||||
const COMMANDS: Record<string, { description: string; response: string }> = {
|
||||
'/help': {
|
||||
description: '도움말 보기',
|
||||
response: `📚 **사용 가능한 명령어**
|
||||
• /help - 도움말 보기
|
||||
• /rules - 채팅방 규칙 보기
|
||||
• /users - 현재 접속자 목록
|
||||
• /pricing - Anvil Hosting 요금 안내
|
||||
• /specs - 서버 스펙 안내
|
||||
• /regions - 리전 정보
|
||||
• /contact - 텔레그램 상담 연결
|
||||
• @방장 [질문] - AI에게 질문하기`,
|
||||
},
|
||||
'/rules': {
|
||||
description: '채팅방 규칙',
|
||||
response: `📋 **채팅방 규칙**
|
||||
1. 서로 존중하며 대화해주세요
|
||||
2. 욕설, 비방, 차별적 발언은 금지입니다
|
||||
3. 스팸 및 광고는 자제해주세요
|
||||
4. 개인정보 공유에 주의해주세요
|
||||
5. 즐거운 대화 부탁드립니다! 😊`,
|
||||
},
|
||||
'/pricing': {
|
||||
description: 'Anvil Hosting 요금',
|
||||
response: anvilHostingInfo.pricing,
|
||||
},
|
||||
'/specs': {
|
||||
description: 'Anvil Hosting 스펙',
|
||||
response: anvilHostingInfo.specs,
|
||||
},
|
||||
'/regions': {
|
||||
description: 'Anvil Hosting 리전',
|
||||
response: anvilHostingInfo.regions,
|
||||
},
|
||||
'/contact': {
|
||||
description: 'Anvil Hosting 문의',
|
||||
response: anvilHostingInfo.contact,
|
||||
},
|
||||
};
|
||||
|
||||
export class AIManager {
|
||||
private openaiKey: string;
|
||||
private config: AIConfig;
|
||||
private conversationHistory: OpenAIMessage[] = [];
|
||||
private readonly MAX_HISTORY = 10;
|
||||
private context7: Context7Client;
|
||||
|
||||
constructor(openaiKey: string, config: AIConfig) {
|
||||
this.openaiKey = openaiKey;
|
||||
this.config = config;
|
||||
this.context7 = new Context7Client();
|
||||
}
|
||||
|
||||
private async executeFunction(name: string, args: Record<string, unknown>): Promise<string> {
|
||||
switch (name) {
|
||||
case 'searchDocumentation': {
|
||||
const topic = args.topic as string;
|
||||
const docs = await this.context7.searchDocs(topic);
|
||||
if (docs) {
|
||||
return `📚 **문서 검색 결과**:\n${docs}`;
|
||||
}
|
||||
return `"${topic}"에 대한 문서를 찾지 못했습니다. 일반적인 지식으로 답변드릴게요.`;
|
||||
}
|
||||
case 'getAnvilHostingInfo': {
|
||||
const category = args.category as keyof typeof anvilHostingInfo;
|
||||
return anvilHostingInfo[category] || 'Anvil Hosting 정보를 찾을 수 없습니다.';
|
||||
}
|
||||
case 'getCurrentDateTime': {
|
||||
const now = new Date();
|
||||
const koreaTime = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
const days = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
||||
const dayName = days[koreaTime.getDay()];
|
||||
|
||||
const year = koreaTime.getFullYear();
|
||||
const month = koreaTime.getMonth() + 1;
|
||||
const date = koreaTime.getDate();
|
||||
const hours = koreaTime.getHours();
|
||||
const minutes = koreaTime.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
const ampm = hours < 12 ? '오전' : '오후';
|
||||
const displayHours = hours % 12 || 12;
|
||||
|
||||
return `📅 **현재 날짜/시간 (한국 기준)**
|
||||
- 날짜: ${year}년 ${month}월 ${date}일 ${dayName}
|
||||
- 시간: ${ampm} ${displayHours}시 ${minutes}분`;
|
||||
}
|
||||
case 'getWeather': {
|
||||
const cityName = (args.city as string || '서울').toLowerCase();
|
||||
const coords = CITY_COORDINATES[cityName] || CITY_COORDINATES['default'];
|
||||
|
||||
try {
|
||||
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}&longitude=${coords.lon}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code&timezone=Asia/Seoul&forecast_days=1`;
|
||||
|
||||
const response = await fetchWithTimeout(weatherUrl, { method: 'GET' }, WEATHER_API_TIMEOUT_MS);
|
||||
|
||||
if (!response.ok) {
|
||||
return `날씨 정보를 가져올 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const parseResult = WeatherApiResponseSchema.safeParse(rawData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Weather API response validation failed:', parseResult.error.message);
|
||||
return `날씨 데이터 형식이 변경되었습니다. 관리자에게 문의해주세요.`;
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
const current = data.current;
|
||||
const daily = data.daily;
|
||||
const weatherDesc = WEATHER_CODES[current.weather_code] || '알 수 없음';
|
||||
const dailyWeatherDesc = WEATHER_CODES[daily.weather_code[0]] || '알 수 없음';
|
||||
|
||||
return `🌡️ **${coords.name} 날씨**
|
||||
|
||||
**현재 날씨**
|
||||
- 상태: ${weatherDesc}
|
||||
- 기온: ${current.temperature_2m}°C
|
||||
- 습도: ${current.relative_humidity_2m}%
|
||||
- 바람: ${current.wind_speed_10m} km/h
|
||||
|
||||
**오늘 예보**
|
||||
- 상태: ${dailyWeatherDesc}
|
||||
- 최고: ${daily.temperature_2m_max[0]}°C
|
||||
- 최저: ${daily.temperature_2m_min[0]}°C`;
|
||||
} catch (error) {
|
||||
// Use sanitized logging to prevent potential credential leaks
|
||||
console.error('Weather API error:', sanitizeErrorForLogging(error));
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return `날씨 정보 조회 시간이 초과되었습니다.`;
|
||||
}
|
||||
return `날씨 정보를 가져오는 중 오류가 발생했습니다.`;
|
||||
}
|
||||
}
|
||||
case 'silenceUser': {
|
||||
const userName = args.userName as string;
|
||||
const reason = args.reason as string || '채팅방 규칙 위반';
|
||||
const durationSeconds = 30;
|
||||
|
||||
if (!userName) {
|
||||
return '묵언수행시킬 사용자 이름이 필요합니다.';
|
||||
}
|
||||
|
||||
// Call the silence callback if provided
|
||||
if (this.config.onSilenceUser) {
|
||||
const success = await this.config.onSilenceUser(userName, durationSeconds, reason);
|
||||
if (success) {
|
||||
return `🔇 ${userName}님이 ${durationSeconds}초간 묵언수행 처리되었습니다. (사유: ${reason})`;
|
||||
} else {
|
||||
return `${userName}님을 찾을 수 없거나 이미 묵언수행 상태입니다.`;
|
||||
}
|
||||
}
|
||||
return '묵언수행 기능을 사용할 수 없습니다.';
|
||||
}
|
||||
default:
|
||||
return '알 수 없는 기능입니다.';
|
||||
}
|
||||
}
|
||||
|
||||
async generateWelcome(userName: string, userCount: number): Promise<string> {
|
||||
const greetings = [
|
||||
`${userName}님, 환영합니다! 🎉`,
|
||||
`안녕하세요 ${userName}님! 반가워요~ 😊`,
|
||||
`${userName}님 입장! 🙌`,
|
||||
`어서오세요 ${userName}님! 🌟`,
|
||||
];
|
||||
const greeting = greetings[Math.floor(Math.random() * greetings.length)];
|
||||
|
||||
// Always include rules reminder in welcome message
|
||||
const rulesReminder = `\n\n⚠️ **채팅방 규칙 안내**\n• 욕설, 비방, 혐오 발언은 금지입니다\n• 위반 시 경고 → 30초 묵언수행 처리됩니다\n• 즐겁고 건전한 대화 부탁드려요! 💬`;
|
||||
|
||||
const tips = [
|
||||
`\n\n현재 ${userCount}명이 대화중이에요. /help로 명령어를 확인해보세요!`,
|
||||
`\n\nIT 질문은 @방장 으로 물어보세요!`,
|
||||
`\n\n즐거운 대화 되세요~`,
|
||||
];
|
||||
const tip = tips[Math.floor(Math.random() * tips.length)];
|
||||
|
||||
return greeting + rulesReminder + tip;
|
||||
}
|
||||
|
||||
async handleCommand(command: string, context: ChatContext): Promise<string | null> {
|
||||
const cmd = command.toLowerCase().trim();
|
||||
|
||||
if (cmd === '/users') {
|
||||
const userList = context.users.join(', ');
|
||||
return `👥 **현재 접속자 (${context.users.length}명)**\n${userList}`;
|
||||
}
|
||||
|
||||
const commandInfo = COMMANDS[cmd];
|
||||
if (commandInfo) {
|
||||
return commandInfo.response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async shouldRespond(message: string, senderName: string): Promise<boolean> {
|
||||
if (senderName === this.config.botName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.includes('@방장')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Question patterns - always respond
|
||||
const questionPatterns = [
|
||||
'?', '알려', '뭐야', '뭐예요', '어떻게', '왜', '설명', '추천',
|
||||
'도와', '질문', '궁금', '모르', '가르쳐', '에 대해', '란', '이란'
|
||||
];
|
||||
|
||||
if (questionPatterns.some(p => lowerMessage.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tech keywords - always respond
|
||||
const techKeywords = [
|
||||
'server', 'docker', 'kubernetes', 'k8s', 'nginx', 'caddy', 'apache',
|
||||
'react', 'vue', 'next', 'node', 'python', 'go', 'rust', 'java',
|
||||
'linux', 'ubuntu', 'debian', 'aws', 'gcp', 'cloudflare',
|
||||
'database', 'postgres', 'mysql', 'redis', 'mongodb',
|
||||
'서버', '도커', '쿠버', '컨테이너', '호스팅', '배포', '클라우드',
|
||||
'리액트', '파이썬', '데이터베이스', 'api', 'ssl', 'https', 'dns',
|
||||
'incus', 'lxd', 'lxc', 'vm', 'vps', '가상', '인스턴스'
|
||||
];
|
||||
|
||||
if (techKeywords.some(k => lowerMessage.includes(k))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Math.random() < 0.15;
|
||||
}
|
||||
|
||||
async generateResponse(message: string, context: ChatContext): Promise<string> {
|
||||
if (message.startsWith('/')) {
|
||||
const cmdResponse = await this.handleCommand(message, context);
|
||||
if (cmdResponse) return cmdResponse;
|
||||
}
|
||||
|
||||
const cleanMessage = message.replace(/@방장/g, '').trim();
|
||||
|
||||
const recentContext = context.recentMessages
|
||||
.slice(-5)
|
||||
.map((m) => `${m.name}: ${m.content}`)
|
||||
.join('\n');
|
||||
|
||||
// Build messages for OpenAI
|
||||
const messages: OpenAIRequestMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.config.systemPrompt || DEFAULT_SYSTEM_PROMPT,
|
||||
},
|
||||
...this.conversationHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: `[채팅방: ${context.roomId}]
|
||||
접속자: ${context.users.join(', ')}
|
||||
최근 대화:
|
||||
${recentContext}
|
||||
|
||||
질문: ${cleanMessage}`,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
// First API call with tools (with timeout)
|
||||
const response = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.openaiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
tools: OPENAI_TOOLS,
|
||||
tool_choice: 'auto',
|
||||
max_tokens: 500,
|
||||
temperature: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('OpenAI API error:', sanitizeErrorForLogging(errorText));
|
||||
return '잠시 문제가 있네요. 다시 말씀해주세요! 🙏';
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const data = parseOpenAIResponse(rawData);
|
||||
if (!data || data.choices.length === 0) {
|
||||
console.error('OpenAI response validation failed or empty choices');
|
||||
return '응답 처리 중 오류가 발생했어요. 다시 시도해주세요! 🔧';
|
||||
}
|
||||
const choice = data.choices[0];
|
||||
|
||||
// Check if the model wants to call a function
|
||||
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
|
||||
const toolCall = choice.message.tool_calls[0];
|
||||
const functionName = toolCall.function.name;
|
||||
|
||||
// Safely parse function arguments
|
||||
let functionArgs: Record<string, unknown>;
|
||||
try {
|
||||
functionArgs = JSON.parse(toolCall.function.arguments);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse function arguments:', sanitizeErrorForLogging(parseError));
|
||||
return '함수 실행 중 오류가 발생했어요. 다시 시도해주세요! 🔧';
|
||||
}
|
||||
|
||||
// Execute the function with error handling
|
||||
let functionResult: string;
|
||||
try {
|
||||
functionResult = await this.executeFunction(functionName, functionArgs);
|
||||
} catch (execError) {
|
||||
console.error('Function execution error:', sanitizeErrorForLogging(execError));
|
||||
functionResult = `함수 실행 오류: ${functionName}`;
|
||||
}
|
||||
|
||||
// Add assistant message with tool call and tool response
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: choice.message.tool_calls,
|
||||
});
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: functionResult,
|
||||
tool_call_id: toolCall.id,
|
||||
});
|
||||
|
||||
// Second API call to get final response with function result (with timeout)
|
||||
const secondResponse = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.openaiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
max_tokens: 500,
|
||||
temperature: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!secondResponse.ok) {
|
||||
// If second call fails, return the function result directly
|
||||
return functionResult;
|
||||
}
|
||||
|
||||
const secondRawData = await secondResponse.json();
|
||||
const secondData = parseOpenAIResponse(secondRawData);
|
||||
// If validation fails, fall back to function result
|
||||
const aiResponse = secondData?.choices[0]?.message?.content || functionResult;
|
||||
|
||||
// Update conversation history (include user name for context isolation)
|
||||
this.conversationHistory.push(
|
||||
{ role: 'user', content: `[${context.currentUser}] ${cleanMessage}` },
|
||||
{ role: 'assistant', content: aiResponse }
|
||||
);
|
||||
|
||||
if (this.conversationHistory.length > this.MAX_HISTORY * 2) {
|
||||
this.conversationHistory = this.conversationHistory.slice(-this.MAX_HISTORY * 2);
|
||||
}
|
||||
|
||||
return aiResponse;
|
||||
}
|
||||
|
||||
// No function call, return direct response
|
||||
const aiResponse = choice.message.content || '응답을 생성하지 못했어요.';
|
||||
|
||||
// Update conversation history (include user name for context isolation)
|
||||
this.conversationHistory.push(
|
||||
{ role: 'user', content: `[${context.currentUser}] ${cleanMessage}` },
|
||||
{ role: 'assistant', content: aiResponse }
|
||||
);
|
||||
|
||||
if (this.conversationHistory.length > this.MAX_HISTORY * 2) {
|
||||
this.conversationHistory = this.conversationHistory.slice(-this.MAX_HISTORY * 2);
|
||||
}
|
||||
|
||||
return aiResponse;
|
||||
} catch (error) {
|
||||
// Check if it's a timeout error
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('OpenAI request timeout');
|
||||
return '응답 시간이 초과되었어요. 잠시 후 다시 시도해주세요! ⏱️';
|
||||
}
|
||||
console.error('OpenAI request error:', sanitizeErrorForLogging(error));
|
||||
return '잠시 생각이 필요해요... 다시 물어봐주세요! 🤔';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 기반 욕설 감지 - 변형된 욕설도 감지 가능
|
||||
* 예: "시ㅂㅏㄹ", "씨X발", "ㅅㅂ", "시1발" 등
|
||||
*/
|
||||
async moderateMessage(message: string): Promise<ModerationResult> {
|
||||
// 빠른 필터: 정규식으로 명확한 욕설 먼저 체크
|
||||
const quickResult = this.quickModerationCheck(message);
|
||||
if (quickResult.isInappropriate) {
|
||||
return quickResult;
|
||||
}
|
||||
|
||||
// 짧은 메시지나 단순 메시지는 AI 체크 스킵
|
||||
if (message.length < 2 || /^[a-zA-Z0-9\s.,!?]+$/.test(message)) {
|
||||
return { isInappropriate: false, severity: 'low' };
|
||||
}
|
||||
|
||||
// AI 기반 욕설 감지
|
||||
try {
|
||||
const response = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.openaiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 한국어/영어 욕설 및 비속어 감지 전문가입니다.
|
||||
사용자 메시지를 분석하여 욕설, 비속어, 혐오 표현을 감지합니다.
|
||||
|
||||
감지해야 할 패턴:
|
||||
1. 직접적인 욕설: 시발, 씨발, 병신, 지랄, 개새끼, 씹새끼, fuck, shit 등
|
||||
2. 변형된 욕설: 시ㅂㅏㄹ, 씨X발, ㅅㅂ, 시1발, 병ㅅ, 개ㅅㄲ, f*ck 등
|
||||
3. 초성/자모 분리: ㅅㅂ, ㅂㅅ, ㅈㄹ, ㄱㅅㄲ 등
|
||||
4. 비방 표현: 바보, 멍청이, 꺼져, 닥쳐, 죽어 등
|
||||
5. 혐오 표현: 인종/성별/장애 관련 비하
|
||||
|
||||
반드시 JSON 형식으로 응답:
|
||||
{
|
||||
"isInappropriate": true/false,
|
||||
"severity": "low" | "medium" | "high",
|
||||
"reason": "감지 사유 (optional)",
|
||||
"detectedWords": ["감지된", "욕설", "단어들"]
|
||||
}
|
||||
|
||||
severity 기준:
|
||||
- high: 직접적인 욕설, 심한 비속어
|
||||
- medium: 비방, 경미한 비속어
|
||||
- low: 문제 없음
|
||||
|
||||
중요: detectedWords에는 원문에서 찾은 정확한 욕설 문자열을 포함하세요.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: message,
|
||||
},
|
||||
],
|
||||
max_tokens: 150,
|
||||
temperature: 0.1,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
}, 5000); // 5초 타임아웃
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('AI moderation API error');
|
||||
return this.quickModerationCheck(message); // 폴백
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const data = parseOpenAIResponse(rawData);
|
||||
const content = data?.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
return this.quickModerationCheck(message);
|
||||
}
|
||||
|
||||
// FIXED: Wrap JSON.parse in try-catch to handle malformed responses
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
console.error('AI moderation response JSON parse failed:', sanitizeErrorForLogging(parseError));
|
||||
return this.quickModerationCheck(message);
|
||||
}
|
||||
|
||||
const validated = AIModerationResponseSchema.safeParse(parsed);
|
||||
|
||||
if (!validated.success) {
|
||||
console.error('AI moderation response validation failed:', validated.error.message);
|
||||
return this.quickModerationCheck(message);
|
||||
}
|
||||
|
||||
return {
|
||||
isInappropriate: validated.data.isInappropriate,
|
||||
severity: validated.data.severity,
|
||||
reason: validated.data.reason,
|
||||
detectedWords: validated.data.detectedWords,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('AI moderation error:', sanitizeErrorForLogging(error));
|
||||
return this.quickModerationCheck(message); // 폴백
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 정규식 기반 욕설 체크 (폴백 및 1차 필터)
|
||||
*/
|
||||
private quickModerationCheck(message: string): ModerationResult {
|
||||
// NOTE: Do NOT use /g flag with test() - it causes stateful behavior bugs
|
||||
const severePatterns: Array<{ pattern: RegExp; word: string }> = [
|
||||
{ pattern: /시발/, word: '시발' },
|
||||
{ pattern: /씨발/, word: '씨발' },
|
||||
{ pattern: /병신/, word: '병신' },
|
||||
{ pattern: /지랄/, word: '지랄' },
|
||||
{ pattern: /개새끼/, word: '개새끼' },
|
||||
{ pattern: /씹새끼/, word: '씹새끼' },
|
||||
{ pattern: /ㅅㅂ/, word: 'ㅅㅂ' },
|
||||
{ pattern: /ㅂㅅ/, word: 'ㅂㅅ' },
|
||||
{ pattern: /ㅈㄹ/, word: 'ㅈㄹ' },
|
||||
{ pattern: /fuck/i, word: 'fuck' },
|
||||
{ pattern: /shit/i, word: 'shit' },
|
||||
];
|
||||
|
||||
const moderatePatterns: Array<{ pattern: RegExp; word: string }> = [
|
||||
{ pattern: /바보/, word: '바보' },
|
||||
{ pattern: /멍청/, word: '멍청' },
|
||||
{ pattern: /꺼져/, word: '꺼져' },
|
||||
{ pattern: /닥쳐/, word: '닥쳐' },
|
||||
];
|
||||
|
||||
const detectedWords: string[] = [];
|
||||
|
||||
for (const { pattern, word } of severePatterns) {
|
||||
if (pattern.test(message)) {
|
||||
detectedWords.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedWords.length > 0) {
|
||||
return {
|
||||
isInappropriate: true,
|
||||
reason: '부적절한 언어 사용',
|
||||
severity: 'high',
|
||||
detectedWords,
|
||||
};
|
||||
}
|
||||
|
||||
for (const { pattern, word } of moderatePatterns) {
|
||||
if (pattern.test(message)) {
|
||||
detectedWords.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedWords.length > 0) {
|
||||
return {
|
||||
isInappropriate: true,
|
||||
reason: '다른 사용자를 존중해주세요',
|
||||
severity: 'medium',
|
||||
detectedWords,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isInappropriate: false,
|
||||
severity: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
generateModerationWarning(result: ModerationResult, userName: string): string {
|
||||
if (result.severity === 'high') {
|
||||
return `⚠️ ${userName}님, 부적절한 언어 사용은 삼가해주세요. 반복 시 제재될 수 있습니다.`;
|
||||
}
|
||||
return `💬 ${userName}님, ${result.reason}. 즐거운 대화 부탁드려요! 😊`;
|
||||
}
|
||||
}
|
||||
844
worker/src/ChatRoom.ts
Normal file
844
worker/src/ChatRoom.ts
Normal file
@@ -0,0 +1,844 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { AIManager, ChatContext } from './AIManager';
|
||||
import { WebSocketMessageSchema, RoomIdSchema, UserNameSchema } from './validation';
|
||||
import { RateLimiter } from './RateLimiter';
|
||||
|
||||
/**
|
||||
* Promise-based delay for safe async execution in Durable Objects
|
||||
* Unlike setTimeout, this properly integrates with async/await
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
name: string;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
type: 'message' | 'join' | 'leave' | 'userList' | 'history';
|
||||
id?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
timestamp: number;
|
||||
users?: string[];
|
||||
isBot?: boolean;
|
||||
messages?: StoredMessage[]; // For history type
|
||||
}
|
||||
|
||||
// Simplified message format for storage
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
interface Env {
|
||||
OPENAI_API_KEY: string;
|
||||
}
|
||||
|
||||
const BOT_NAME = '방장';
|
||||
const BOT_ID = 'ai-manager-bot';
|
||||
const STORAGE_KEY_MESSAGES = 'chat_messages';
|
||||
const MAX_STORED_MESSAGES = 50;
|
||||
const DEFAULT_SILENCE_DURATION_SECONDS = 30;
|
||||
const AUTO_SILENCE_THRESHOLD = 3; // Number of violations before auto-silence
|
||||
const VIOLATION_WINDOW_MS = 60000; // 1 minute window for counting violations
|
||||
|
||||
// Storage retry configuration
|
||||
const STORAGE_MAX_RETRIES = 3;
|
||||
const STORAGE_RETRY_BASE_DELAY_MS = 50;
|
||||
|
||||
/**
|
||||
* AI가 감지한 욕설 단어들을 마스킹
|
||||
* 첫 글자만 남기고 나머지는 * 로 대체
|
||||
* Example: "시발" → "시*", "개새끼" → "개**"
|
||||
*/
|
||||
function maskDetectedWords(content: string, detectedWords: string[]): string {
|
||||
if (!detectedWords || detectedWords.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let masked = content;
|
||||
|
||||
for (const word of detectedWords) {
|
||||
if (!word || word.length === 0) continue;
|
||||
|
||||
// Escape special regex characters in the word
|
||||
const escapedWord = word.replace(/[.*+?^${}()|[\\]/g, '\\$&');
|
||||
const pattern = new RegExp(escapedWord, 'gi');
|
||||
|
||||
masked = masked.replace(pattern, (match) => {
|
||||
if (match.length <= 1) return '*';
|
||||
// Keep first character, replace rest with asterisks
|
||||
return match[0] + '*'.repeat(match.length - 1);
|
||||
});
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴백용: 기본 패턴 기반 마스킹 (AI 실패 시 사용)
|
||||
*/
|
||||
function maskProfanityFallback(content: string): { masked: string; hadProfanity: boolean } {
|
||||
const patterns = [
|
||||
/시발/g, /씨발/g, /병신/g, /지랄/g, /개새끼/g, /씹새끼/g,
|
||||
/ㅅㅂ/g, /ㅂㅅ/g, /ㅈㄹ/g, /fuck/gi, /shit/gi,
|
||||
/바보/g, /멍청/g, /꺼져/g, /닥쳐/g,
|
||||
];
|
||||
|
||||
let masked = content;
|
||||
let hadProfanity = false;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
pattern.lastIndex = 0;
|
||||
masked = masked.replace(pattern, (match) => {
|
||||
hadProfanity = true;
|
||||
if (match.length <= 1) return '*';
|
||||
return match[0] + '*'.repeat(match.length - 1);
|
||||
});
|
||||
}
|
||||
|
||||
return { masked, hadProfanity };
|
||||
}
|
||||
|
||||
// Track silenced users with expiry time
|
||||
interface SilenceEntry {
|
||||
expiresAt: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Track moderation violations per user
|
||||
interface ViolationEntry {
|
||||
count: number;
|
||||
firstViolationAt: number;
|
||||
}
|
||||
|
||||
export class ChatRoom extends DurableObject<Env> {
|
||||
private aiManager: AIManager | null = null;
|
||||
private recentMessages: Array<{ name: string; content: string }> = [];
|
||||
private storedMessages: StoredMessage[] = [];
|
||||
private messagesLoaded: boolean = false;
|
||||
// Note: silencedUsers and userViolations now use DO storage for persistence across hibernation
|
||||
// Storage keys: "silence:{userName}" and "violations:{userName}"
|
||||
private readonly MAX_RECENT_MESSAGES = 20;
|
||||
private roomId: string = 'default';
|
||||
|
||||
// Instance-level rate limiters
|
||||
private aiRateLimiter: RateLimiter;
|
||||
private moderationRateLimiter: RateLimiter;
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env);
|
||||
// Initialize rate limiters as instance properties
|
||||
this.aiRateLimiter = new RateLimiter({
|
||||
maxRequests: 15, // 15 AI requests
|
||||
windowMs: 60 * 1000, // per minute
|
||||
blockDurationMs: 2 * 60 * 1000, // 2 minute block
|
||||
});
|
||||
|
||||
this.moderationRateLimiter = new RateLimiter({
|
||||
maxRequests: 3, // 3 violations
|
||||
windowMs: 5 * 60 * 1000, // per 5 minutes
|
||||
blockDurationMs: 30 * 60 * 1000, // 30 minute block
|
||||
});
|
||||
}
|
||||
|
||||
private getAIManager(): AIManager {
|
||||
if (!this.aiManager) {
|
||||
this.aiManager = new AIManager(this.env.OPENAI_API_KEY, {
|
||||
botName: BOT_NAME,
|
||||
roomId: this.roomId,
|
||||
onSilenceUser: this.silenceUser.bind(this),
|
||||
});
|
||||
}
|
||||
return this.aiManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence a user for a specified duration
|
||||
* Returns true if successful, false if user not found or already silenced
|
||||
* Uses DO storage for persistence across hibernation
|
||||
* FIXED: Uses transaction with retry logic
|
||||
*/
|
||||
private async silenceUser(userName: string, durationSeconds: number, reason: string): Promise<boolean> {
|
||||
// Find the user in current sessions
|
||||
const users = this.getUserList();
|
||||
if (!users.includes(userName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageKey = `silence:${userName}`;
|
||||
|
||||
for (let attempt = 1; attempt <= STORAGE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Use transaction for atomic check-and-set
|
||||
const result = await this.ctx.storage.transaction(async (txn) => {
|
||||
const existing = await txn.get<SilenceEntry>(storageKey);
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return { success: false, expiresAt: 0 }; // Already silenced
|
||||
}
|
||||
|
||||
// Silence the user
|
||||
const expiresAt = Date.now() + (durationSeconds * 1000);
|
||||
await txn.put(storageKey, { expiresAt, reason });
|
||||
return { success: true, expiresAt };
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return false; // Already silenced
|
||||
}
|
||||
|
||||
// Notify the silenced user via their WebSocket
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const session = this.getSession(ws);
|
||||
if (session && session.name === userName) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `🔇 ${durationSeconds}초간 묵언수행 처리되었습니다. (사유: ${reason})`,
|
||||
timestamp: Date.now(),
|
||||
silencedUntil: result.expiresAt,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`silenceUser storage error (attempt ${attempt}/${STORAGE_MAX_RETRIES}):`, error);
|
||||
if (attempt < STORAGE_MAX_RETRIES) {
|
||||
const jitter = Math.random() * STORAGE_RETRY_BASE_DELAY_MS;
|
||||
await delay(STORAGE_RETRY_BASE_DELAY_MS * attempt + jitter);
|
||||
} else {
|
||||
console.error('CRITICAL: silenceUser failed after all retries');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is currently silenced
|
||||
* Uses DO storage for persistence across hibernation
|
||||
* FIXED: Uses transaction to prevent TOCTOU race condition
|
||||
*/
|
||||
private async isUserSilenced(userName: string): Promise<{ silenced: boolean; remainingSeconds?: number; reason?: string }> {
|
||||
const storageKey = `silence:${userName}`;
|
||||
|
||||
// Use transaction to ensure atomic check-and-delete operation
|
||||
// This prevents race conditions where multiple concurrent checks could
|
||||
// see the entry before any of them deletes it
|
||||
const result = await this.ctx.storage.transaction(async (txn) => {
|
||||
const entry = await txn.get<SilenceEntry>(storageKey);
|
||||
if (!entry) {
|
||||
return { silenced: false };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (entry.expiresAt <= now) {
|
||||
// Silence expired, atomically remove entry within transaction
|
||||
await txn.delete(storageKey);
|
||||
return { silenced: false };
|
||||
}
|
||||
|
||||
return {
|
||||
silenced: true,
|
||||
remainingSeconds: Math.ceil((entry.expiresAt - now) / 1000),
|
||||
reason: entry.reason,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a moderation violation and check if auto-silence should be triggered
|
||||
* Uses DO storage for persistence across hibernation
|
||||
* FIXED: Uses transaction for atomic read-modify-write with retry logic
|
||||
* Returns the current violation count after incrementing
|
||||
*/
|
||||
private async trackViolation(userName: string): Promise<number> {
|
||||
const storageKey = `violations:${userName}`;
|
||||
|
||||
for (let attempt = 1; attempt <= STORAGE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Use transaction for atomic read-modify-write
|
||||
const result = await this.ctx.storage.transaction(async (txn) => {
|
||||
const now = Date.now();
|
||||
const existing = await txn.get<ViolationEntry>(storageKey);
|
||||
|
||||
if (existing) {
|
||||
// Check if the violation window has expired
|
||||
if (now - existing.firstViolationAt > VIOLATION_WINDOW_MS) {
|
||||
// Reset the counter - start a new window
|
||||
await txn.put(storageKey, { count: 1, firstViolationAt: now });
|
||||
return 1;
|
||||
}
|
||||
// Increment within the same window
|
||||
const newCount = existing.count + 1;
|
||||
await txn.put(storageKey, { count: newCount, firstViolationAt: existing.firstViolationAt });
|
||||
return newCount;
|
||||
}
|
||||
|
||||
// First violation for this user
|
||||
await txn.put(storageKey, { count: 1, firstViolationAt: now });
|
||||
return 1;
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`trackViolation storage error (attempt ${attempt}/${STORAGE_MAX_RETRIES}):`, error);
|
||||
if (attempt < STORAGE_MAX_RETRIES) {
|
||||
// Add jitter to prevent thundering herd
|
||||
const jitter = Math.random() * STORAGE_RETRY_BASE_DELAY_MS;
|
||||
await delay(STORAGE_RETRY_BASE_DELAY_MS * attempt + jitter);
|
||||
} else {
|
||||
// All retries failed, return 1 to be safe (don't auto-silence on storage error)
|
||||
console.error('CRITICAL: trackViolation failed after all retries');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset violation count for a user (called after silencing)
|
||||
*/
|
||||
private async resetViolations(userName: string): Promise<void> {
|
||||
const storageKey = `violations:${userName}`;
|
||||
await this.ctx.storage.delete(storageKey);
|
||||
}
|
||||
|
||||
private getSession(ws: WebSocket): Session | null {
|
||||
try {
|
||||
return ws.deserializeAttachment() as Session | null;
|
||||
} catch (error) {
|
||||
console.error('Failed to deserialize session attachment:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely serialize session to WebSocket attachment
|
||||
* Returns true if successful, false if serialization failed
|
||||
*/
|
||||
private setSession(ws: WebSocket, session: Session): boolean {
|
||||
try {
|
||||
ws.serializeAttachment(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to serialize session attachment:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getUserList(): string[] {
|
||||
const users: string[] = [];
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const session = this.getSession(ws);
|
||||
if (session) {
|
||||
users.push(session.name);
|
||||
}
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
private getChatContext(currentUser: string): ChatContext {
|
||||
return {
|
||||
recentMessages: this.recentMessages.slice(-10),
|
||||
users: this.getUserList(),
|
||||
roomId: this.roomId,
|
||||
currentUser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load message history from DO storage
|
||||
* Called lazily when first needed to avoid unnecessary storage reads
|
||||
*/
|
||||
private async loadMessages(): Promise<void> {
|
||||
if (this.messagesLoaded) return;
|
||||
|
||||
try {
|
||||
const stored = await this.ctx.storage.get<StoredMessage[]>(STORAGE_KEY_MESSAGES);
|
||||
if (stored && Array.isArray(stored)) {
|
||||
this.storedMessages = stored;
|
||||
// Populate recentMessages for AI context
|
||||
this.recentMessages = stored.slice(-this.MAX_RECENT_MESSAGES).map(m => ({
|
||||
name: m.name,
|
||||
content: m.content,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages from storage:', error);
|
||||
}
|
||||
|
||||
this.messagesLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a message to storage
|
||||
* Fixed: Now properly awaits storage operation with retry logic
|
||||
* Returns true if saved successfully, false if failed after retries
|
||||
*/
|
||||
private async saveMessage(message: StoredMessage): Promise<boolean> {
|
||||
this.storedMessages.push(message);
|
||||
|
||||
// Keep only the most recent messages
|
||||
if (this.storedMessages.length > MAX_STORED_MESSAGES) {
|
||||
this.storedMessages = this.storedMessages.slice(-MAX_STORED_MESSAGES);
|
||||
}
|
||||
|
||||
// Retry logic for storage operations
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 100;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.ctx.storage.put(STORAGE_KEY_MESSAGES, this.storedMessages);
|
||||
return true; // Success
|
||||
} catch (error) {
|
||||
console.error(`Failed to save message to storage (attempt ${attempt}/${MAX_RETRIES}):`, error);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Wait before retry with exponential backoff
|
||||
await delay(RETRY_DELAY_MS * attempt);
|
||||
} else {
|
||||
// All retries failed - remove message from memory to maintain consistency
|
||||
// This prevents the in-memory state from diverging from storage
|
||||
const messageIndex = this.storedMessages.findIndex(
|
||||
m => m.id === message.id && m.timestamp === message.timestamp
|
||||
);
|
||||
if (messageIndex !== -1) {
|
||||
this.storedMessages.splice(messageIndex, 1);
|
||||
}
|
||||
|
||||
// Log critical error for monitoring/alerting
|
||||
console.error('CRITICAL: Message storage failed after all retries. Message may be lost:', {
|
||||
messageId: message.id,
|
||||
userName: message.name,
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Extract and validate room ID from URL
|
||||
const roomMatch = url.pathname.match(/\/rooms\/([^/]+)\//);
|
||||
if (roomMatch) {
|
||||
const roomIdResult = RoomIdSchema.safeParse(roomMatch[1]);
|
||||
if (!roomIdResult.success) {
|
||||
return new Response('Invalid room ID', { status: 400 });
|
||||
}
|
||||
this.roomId = roomIdResult.data;
|
||||
}
|
||||
|
||||
// Handle WebSocket upgrade
|
||||
if (request.headers.get('Upgrade') === 'websocket') {
|
||||
return this.handleWebSocket(request);
|
||||
}
|
||||
|
||||
// Handle REST endpoints
|
||||
if (url.pathname.endsWith('/users')) {
|
||||
return this.handleGetUsers();
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
private async handleWebSocket(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const rawName = url.searchParams.get('name') || 'Anonymous';
|
||||
|
||||
// Validate user name
|
||||
const nameResult = UserNameSchema.safeParse(rawName);
|
||||
const name = nameResult.success ? nameResult.data : 'Anonymous';
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
// Load message history from storage (if not already loaded)
|
||||
await this.loadMessages();
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = Object.values(pair);
|
||||
|
||||
// Store session data using serializeAttachment for hibernation support
|
||||
const session: Session = {
|
||||
id: userId,
|
||||
name,
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Safely serialize session - if this fails, connection should still work but may lose state on hibernation
|
||||
if (!this.setSession(server, session)) {
|
||||
console.warn(`Session serialization failed for user ${name}, connection may not survive hibernation`);
|
||||
}
|
||||
|
||||
// Accept WebSocket with Hibernation API
|
||||
this.ctx.acceptWebSocket(server);
|
||||
|
||||
// Send message history to the new user (last 20 messages)
|
||||
if (this.storedMessages.length > 0) {
|
||||
server.send(JSON.stringify({
|
||||
type: 'history',
|
||||
messages: this.storedMessages.slice(-20),
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Notify all users about the new join
|
||||
this.broadcast({
|
||||
type: 'join',
|
||||
id: userId,
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Send current user list to the new user
|
||||
server.send(JSON.stringify({
|
||||
type: 'userList',
|
||||
users: this.getUserList(),
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
// AI bot welcomes the new user
|
||||
this.sendBotWelcome(name);
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
private async sendBotWelcome(userName: string): Promise<void> {
|
||||
const ai = this.getAIManager();
|
||||
const userCount = this.ctx.getWebSockets().length;
|
||||
const welcome = await ai.generateWelcome(userName, userCount);
|
||||
|
||||
// Small delay to make it feel more natural
|
||||
await delay(500);
|
||||
const timestamp = Date.now();
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: welcome,
|
||||
timestamp,
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Save bot message to storage
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: welcome,
|
||||
timestamp,
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
|
||||
private handleGetUsers(): Response {
|
||||
const users = this.getUserList();
|
||||
return Response.json({ users });
|
||||
}
|
||||
|
||||
private broadcast(message: ChatMessage, exclude?: WebSocket): void {
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
if (ws !== exclude) {
|
||||
try {
|
||||
ws.send(messageStr);
|
||||
} catch (error) {
|
||||
// WebSocket might be closed - log for monitoring but don't throw
|
||||
// Will be cleaned up in webSocketClose handler
|
||||
console.warn('Failed to send message to WebSocket:', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hibernation API: Called when a message is received
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
const session = this.getSession(ws);
|
||||
if (!session) {
|
||||
console.error('No session found for WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData = JSON.parse(message as string);
|
||||
|
||||
// Validate incoming message with Zod
|
||||
const parseResult = WebSocketMessageSchema.safeParse(rawData);
|
||||
if (!parseResult.success) {
|
||||
console.warn('Invalid message format:', parseResult.error.message);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid message format',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
|
||||
if (data.type === 'message') {
|
||||
const content = data.content;
|
||||
|
||||
// Check if user is silenced before allowing message
|
||||
const silenceStatus = await this.isUserSilenced(session.name);
|
||||
if (silenceStatus.silenced) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `🔇 아직 묵언수행 중입니다. ${silenceStatus.remainingSeconds}초 후에 다시 시도해주세요.`,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const ai = this.getAIManager();
|
||||
|
||||
// SECURITY: Moderate BEFORE broadcasting to prevent inappropriate content from being visible
|
||||
const modResult = await ai.moderateMessage(content);
|
||||
|
||||
if (modResult.isInappropriate) {
|
||||
// Track violation count for auto-silence (uses DO storage)
|
||||
const violationCount = await this.trackViolation(session.name);
|
||||
|
||||
// Check if user should be auto-silenced (3+ violations within window)
|
||||
if (violationCount >= AUTO_SILENCE_THRESHOLD) {
|
||||
// Auto-silence the user
|
||||
const silenced = await this.silenceUser(
|
||||
session.name,
|
||||
DEFAULT_SILENCE_DURATION_SECONDS,
|
||||
'부적절한 언어 반복 사용'
|
||||
);
|
||||
|
||||
if (silenced) {
|
||||
// Reset violation count after silencing
|
||||
await this.resetViolations(session.name);
|
||||
|
||||
// Announce the silence publicly
|
||||
await delay(300);
|
||||
const silenceAnnounceTimestamp = Date.now();
|
||||
const silenceAnnouncement = `🔇 ${session.name}님이 부적절한 언어 반복 사용으로 ${DEFAULT_SILENCE_DURATION_SECONDS}초간 묵언수행 처리되었습니다.`;
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: silenceAnnouncement,
|
||||
timestamp: silenceAnnounceTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: silenceAnnouncement,
|
||||
timestamp: silenceAnnounceTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track moderation violations for rate limiting (backup mechanism)
|
||||
const modKey = `mod:${this.roomId}:${session.id}`;
|
||||
const modLimit = this.moderationRateLimiter.check(modKey);
|
||||
|
||||
if (!modLimit.allowed) {
|
||||
// User is blocked due to repeated violations - don't broadcast at all
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `부적절한 메시지가 반복되어 ${modLimit.retryAfter}초간 차단되었습니다.`,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Violation within limit: mask profanity and broadcast the message, then follow with warning
|
||||
// AI가 감지한 단어로 마스킹, 없으면 폴백 패턴 사용
|
||||
let maskedContent: string;
|
||||
if (modResult.detectedWords && modResult.detectedWords.length > 0) {
|
||||
maskedContent = maskDetectedWords(content, modResult.detectedWords);
|
||||
} else {
|
||||
const fallbackResult = maskProfanityFallback(content);
|
||||
maskedContent = fallbackResult.masked;
|
||||
}
|
||||
const msgTimestamp = Date.now();
|
||||
const chatMessage: ChatMessage = {
|
||||
type: 'message',
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content: maskedContent, // Use masked content for broadcast
|
||||
timestamp: msgTimestamp,
|
||||
};
|
||||
this.broadcast(chatMessage);
|
||||
|
||||
// Store masked content in recent messages and save to storage
|
||||
this.recentMessages.push({ name: session.name, content: maskedContent });
|
||||
if (this.recentMessages.length > this.MAX_RECENT_MESSAGES) {
|
||||
this.recentMessages.shift();
|
||||
}
|
||||
await this.saveMessage({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content: maskedContent, // Store masked version
|
||||
timestamp: msgTimestamp,
|
||||
});
|
||||
|
||||
// Send moderation warning with violation count
|
||||
const remainingWarnings = AUTO_SILENCE_THRESHOLD - violationCount;
|
||||
const warning = `⚠️ ${session.name}님, 부적절한 언어 사용은 삼가해주세요. (경고 ${violationCount}/${AUTO_SILENCE_THRESHOLD} - ${remainingWarnings}회 더 위반 시 묵언수행)`;
|
||||
await delay(300);
|
||||
const warnTimestamp = Date.now();
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: warning,
|
||||
timestamp: warnTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: warning,
|
||||
timestamp: warnTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Message is clean - broadcast normally
|
||||
const cleanMsgTimestamp = Date.now();
|
||||
const chatMessage: ChatMessage = {
|
||||
type: 'message',
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content: content,
|
||||
timestamp: cleanMsgTimestamp,
|
||||
};
|
||||
this.broadcast(chatMessage);
|
||||
|
||||
// Store in recent messages and save to storage
|
||||
this.recentMessages.push({ name: session.name, content });
|
||||
if (this.recentMessages.length > this.MAX_RECENT_MESSAGES) {
|
||||
this.recentMessages.shift();
|
||||
}
|
||||
await this.saveMessage({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content,
|
||||
timestamp: cleanMsgTimestamp,
|
||||
});
|
||||
|
||||
// Check if AI should respond
|
||||
const shouldRespond = await ai.shouldRespond(content, session.name);
|
||||
if (shouldRespond) {
|
||||
// Rate limit AI requests per user (not per room)
|
||||
const aiKey = `ai:${this.roomId}:${session.id}`;
|
||||
const rateLimit = this.aiRateLimiter.check(aiKey);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
// Notify user they're rate limited
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `요청이 너무 많습니다. ${rateLimit.retryAfter}초 후 다시 시도해주세요.`,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.getChatContext(session.name);
|
||||
const response = await ai.generateResponse(content, context);
|
||||
|
||||
// Send AI response with slight delay
|
||||
await delay(800);
|
||||
const aiResponseTimestamp = Date.now();
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: response,
|
||||
timestamp: aiResponseTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Save AI response to storage
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: response,
|
||||
timestamp: aiResponseTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'rename') {
|
||||
// Validate new name
|
||||
const nameResult = UserNameSchema.safeParse(data.name);
|
||||
if (!nameResult.success) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: '유효하지 않은 이름입니다. (1-50자)',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
session.name = nameResult.data;
|
||||
|
||||
// Safely serialize updated session
|
||||
if (!this.setSession(ws, session)) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: '이름 변경 중 오류가 발생했습니다.',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify all users about the rename
|
||||
this.broadcast({
|
||||
type: 'userList',
|
||||
users: this.getUserList(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Failed to process message',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Hibernation API: Called when WebSocket is closed
|
||||
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||
const session = this.getSession(ws);
|
||||
if (session) {
|
||||
// Notify remaining users
|
||||
this.broadcast({
|
||||
type: 'leave',
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hibernation API: Called when WebSocket error occurs
|
||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||
console.error('WebSocket error:', error);
|
||||
}
|
||||
}
|
||||
328
worker/src/Context7Client.ts
Normal file
328
worker/src/Context7Client.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Context7 API Client
|
||||
* Documentation lookup service for technical questions
|
||||
*/
|
||||
|
||||
const CONTEXT7_API = 'https://api.context7.com/v1';
|
||||
const CONTEXT7_TIMEOUT_MS = 10000; // 10 seconds timeout
|
||||
|
||||
/**
|
||||
* Fetch with timeout using AbortController
|
||||
* Prevents hanging requests from blocking the chat AI loop
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number = CONTEXT7_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
interface LibraryMatch {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DocResult {
|
||||
content: string;
|
||||
source: string;
|
||||
relevance: number;
|
||||
}
|
||||
|
||||
export class Context7Client {
|
||||
private cache: Map<string, { data: unknown; expires: number }> = new Map();
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly MAX_CACHE_SIZE = 100; // Prevent unbounded cache growth
|
||||
private lastCleanup: number = 0;
|
||||
private readonly CLEANUP_INTERVAL = 60 * 1000; // Run cleanup at most once per minute
|
||||
|
||||
/**
|
||||
* Resolve a library name to Context7 library ID
|
||||
* FIXED: Now uses fetchWithTimeout to prevent hanging requests
|
||||
*/
|
||||
async resolveLibrary(libraryName: string, query: string): Promise<LibraryMatch | null> {
|
||||
const cacheKey = `lib:${libraryName}`;
|
||||
const cached = this.getFromCache<LibraryMatch>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${CONTEXT7_API}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryName, query }),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const result = await response.json() as LibraryMatch;
|
||||
this.setCache(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Handle timeout errors gracefully
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('Context7 resolve timeout');
|
||||
} else {
|
||||
console.error('Context7 resolve error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query documentation for a specific library
|
||||
* FIXED: Now uses fetchWithTimeout to prevent hanging requests
|
||||
*/
|
||||
async queryDocs(libraryId: string, query: string): Promise<DocResult[] | null> {
|
||||
const cacheKey = `docs:${libraryId}:${query}`;
|
||||
const cached = this.getFromCache<DocResult[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${CONTEXT7_API}/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, query }),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const result = await response.json() as DocResult[];
|
||||
this.setCache(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Handle timeout errors gracefully
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('Context7 query timeout');
|
||||
} else {
|
||||
console.error('Context7 query error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: Search documentation for a topic
|
||||
*/
|
||||
async searchDocs(topic: string): Promise<string | null> {
|
||||
// Extract library name from topic
|
||||
const libraryPatterns: Record<string, string> = {
|
||||
'react': 'react',
|
||||
'vue': 'vue',
|
||||
'next': 'next.js',
|
||||
'nuxt': 'nuxt',
|
||||
'node': 'node.js',
|
||||
'express': 'express',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'django': 'django',
|
||||
'fastapi': 'fastapi',
|
||||
'docker': 'docker',
|
||||
'kubernetes': 'kubernetes',
|
||||
'k8s': 'kubernetes',
|
||||
'postgres': 'postgresql',
|
||||
'mysql': 'mysql',
|
||||
'redis': 'redis',
|
||||
'mongodb': 'mongodb',
|
||||
'cloudflare': 'cloudflare workers',
|
||||
'incus': 'incus',
|
||||
'lxd': 'lxd',
|
||||
};
|
||||
|
||||
const lowerTopic = topic.toLowerCase();
|
||||
let libraryName = '';
|
||||
|
||||
for (const [pattern, lib] of Object.entries(libraryPatterns)) {
|
||||
if (lowerTopic.includes(pattern)) {
|
||||
libraryName = lib;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!libraryName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const library = await this.resolveLibrary(libraryName, topic);
|
||||
if (!library) return null;
|
||||
|
||||
const docs = await this.queryDocs(library.id, topic);
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
// Format the results
|
||||
return docs
|
||||
.slice(0, 3)
|
||||
.map(d => d.content)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
private getFromCache<T>(key: string): T | null {
|
||||
// Opportunistic cleanup on read
|
||||
this.cleanupIfNeeded();
|
||||
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
private setCache(key: string, data: unknown): void {
|
||||
// Cleanup before adding if cache is at max size
|
||||
if (this.cache.size >= this.MAX_CACHE_SIZE) {
|
||||
this.cleanupExpired();
|
||||
|
||||
// If still at max size after cleanup, remove oldest entries
|
||||
if (this.cache.size >= this.MAX_CACHE_SIZE) {
|
||||
this.evictOldest(Math.floor(this.MAX_CACHE_SIZE * 0.2)); // Remove 20% of entries
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
expires: Date.now() + this.CACHE_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired cache entries
|
||||
* Called opportunistically to prevent memory leaks
|
||||
*/
|
||||
private cleanupExpired(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.expires <= now) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
this.lastCleanup = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run cleanup only if enough time has passed since last cleanup
|
||||
*/
|
||||
private cleanupIfNeeded(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastCleanup >= this.CLEANUP_INTERVAL) {
|
||||
this.cleanupExpired();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest entries when cache is full
|
||||
*/
|
||||
private evictOldest(count: number): void {
|
||||
const entries = Array.from(this.cache.entries())
|
||||
.sort((a, b) => a[1].expires - b[1].expires);
|
||||
|
||||
for (let i = 0; i < Math.min(count, entries.length); i++) {
|
||||
this.cache.delete(entries[i][0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to force cache cleanup (for testing or manual trigger)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
this.lastCleanup = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Tool definitions for function calling
|
||||
export const context7Tools = [
|
||||
{
|
||||
name: 'searchDocumentation',
|
||||
description: '프로그래밍 라이브러리나 프레임워크의 공식 문서를 검색합니다. React, Vue, Node.js, Docker, Kubernetes 등의 기술 문서를 조회할 수 있습니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: '검색할 기술 주제 (예: "React hooks 사용법", "Docker compose 설정")',
|
||||
},
|
||||
},
|
||||
required: ['topic'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'getAnvilHostingInfo',
|
||||
description: 'Anvil Hosting 서비스 정보를 조회합니다. 가격, 스펙, 리전 정보 등을 제공합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['pricing', 'specs', 'regions', 'features'],
|
||||
description: '조회할 정보 카테고리',
|
||||
},
|
||||
},
|
||||
required: ['category'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Anvil Hosting information (could be fetched from API in production)
|
||||
export const anvilHostingInfo = {
|
||||
pricing: `**Anvil Hosting 요금제**
|
||||
- Starter: $5/월 - 1 vCPU, 1GB RAM, 20GB SSD
|
||||
- Pro: $15/월 - 2 vCPU, 4GB RAM, 80GB SSD
|
||||
- Business: $40/월 - 4 vCPU, 8GB RAM, 160GB SSD
|
||||
- Enterprise: 맞춤형 - 문의 필요
|
||||
|
||||
모든 요금제에 무제한 트래픽 포함!
|
||||
|
||||
👉 **바로 상담받기**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
specs: `**Anvil Hosting 서버 스펙**
|
||||
- CPU: AMD EPYC / Intel Xeon
|
||||
- 스토리지: NVMe SSD (초고속)
|
||||
- 네트워크: 10Gbps 업링크
|
||||
- 가상화: Incus/LXD (컨테이너 & VM)
|
||||
- OS: Ubuntu, Debian, Rocky, Alpine 등
|
||||
|
||||
👉 **자세한 문의**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
regions: `**Anvil Hosting 리전**
|
||||
- 🇰🇷 kr1 (한국) - 서울 데이터센터
|
||||
- 🇯🇵 jp1 (일본) - 도쿄 데이터센터
|
||||
|
||||
아시아 사용자에게 최적화된 저지연 네트워크!
|
||||
|
||||
👉 **리전 상담**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
features: `**Anvil Hosting 특징**
|
||||
- Incus/LXD 기반 컨테이너 & VM
|
||||
- 실시간 스냅샷 & 백업
|
||||
- 간편한 웹 콘솔
|
||||
- API & CLI 지원
|
||||
- 24/7 기술지원
|
||||
- DDoS 방어 기본 제공
|
||||
|
||||
👉 **무료 체험 신청**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
contact: `**Anvil Hosting 문의하기**
|
||||
|
||||
📱 **텔레그램 봇**: https://t.me/AnvilForgeBot
|
||||
- 실시간 상담
|
||||
- 요금제 안내
|
||||
- 무료 체험 신청
|
||||
- 기술 지원 요청
|
||||
|
||||
🌐 **홈페이지**: https://anvil.it.com
|
||||
📧 **이메일**: support@anvil.it.com
|
||||
|
||||
지금 바로 텔레그램으로 문의하세요! 빠른 답변 드립니다.`,
|
||||
};
|
||||
134
worker/src/RateLimiter.ts
Normal file
134
worker/src/RateLimiter.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Simple sliding window rate limiter
|
||||
* Tracks requests per user/room and enforces limits
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
timestamps: number[];
|
||||
blocked: boolean;
|
||||
blockedUntil: number;
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
maxRequests: number; // Maximum requests allowed
|
||||
windowMs: number; // Time window in milliseconds
|
||||
blockDurationMs: number; // How long to block after limit exceeded
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: RateLimitConfig = {
|
||||
maxRequests: 10, // 10 requests
|
||||
windowMs: 60 * 1000, // per minute
|
||||
blockDurationMs: 5 * 60 * 1000, // 5 minute block
|
||||
};
|
||||
|
||||
export class RateLimiter {
|
||||
private entries: Map<string, RateLimitEntry> = new Map();
|
||||
private config: RateLimitConfig;
|
||||
private lastCleanup: number = Date.now();
|
||||
private readonly CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every 60 seconds
|
||||
|
||||
constructor(config: Partial<RateLimitConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed for the given key
|
||||
* @param key Unique identifier (e.g., "room:user" or "ip:address")
|
||||
* @returns Object with allowed status and remaining requests
|
||||
*/
|
||||
check(key: string): { allowed: boolean; remaining: number; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
|
||||
// Automatic cleanup: run every CLEANUP_INTERVAL_MS to prevent memory leak
|
||||
if (now - this.lastCleanup > this.CLEANUP_INTERVAL_MS) {
|
||||
this.cleanup();
|
||||
this.lastCleanup = now;
|
||||
}
|
||||
|
||||
let entry = this.entries.get(key);
|
||||
|
||||
// Create new entry if doesn't exist
|
||||
if (!entry) {
|
||||
entry = { timestamps: [], blocked: false, blockedUntil: 0 };
|
||||
this.entries.set(key, entry);
|
||||
}
|
||||
|
||||
// Check if currently blocked
|
||||
if (entry.blocked) {
|
||||
if (now < entry.blockedUntil) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.ceil((entry.blockedUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
// Block expired, reset
|
||||
entry.blocked = false;
|
||||
entry.timestamps = [];
|
||||
}
|
||||
|
||||
// Clean old timestamps outside the window
|
||||
const windowStart = now - this.config.windowMs;
|
||||
entry.timestamps = entry.timestamps.filter(ts => ts > windowStart);
|
||||
|
||||
// Check if limit exceeded
|
||||
if (entry.timestamps.length >= this.config.maxRequests) {
|
||||
entry.blocked = true;
|
||||
entry.blockedUntil = now + this.config.blockDurationMs;
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.ceil(this.config.blockDurationMs / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Record this request
|
||||
entry.timestamps.push(now);
|
||||
const remaining = this.config.maxRequests - entry.timestamps.length;
|
||||
|
||||
return { allowed: true, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries (call periodically)
|
||||
* Fixed: More aggressive cleanup to prevent memory accumulation
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const windowStart = now - this.config.windowMs;
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
// Filter out stale timestamps first
|
||||
const activeTimestamps = entry.timestamps.filter(ts => ts >= windowStart);
|
||||
|
||||
// Case 1: Block has expired - always delete regardless of timestamps
|
||||
// Once unblocked, the entry will be recreated fresh if user returns
|
||||
if (entry.blocked && now > entry.blockedUntil) {
|
||||
this.entries.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: Not blocked and no recent activity - safe to delete
|
||||
if (!entry.blocked && activeTimestamps.length === 0) {
|
||||
this.entries.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: Entry is still active - update timestamps to remove stale ones
|
||||
// This prevents timestamp array from growing indefinitely
|
||||
if (activeTimestamps.length !== entry.timestamps.length) {
|
||||
entry.timestamps = activeTimestamps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instances should be created within the Durable Object or Worker class
|
||||
// to ensure proper lifecycle management and state isolation.
|
||||
98
worker/src/config.ts
Normal file
98
worker/src/config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Centralized configuration for the chat worker
|
||||
* All magic numbers and hardcoded values should be defined here
|
||||
*/
|
||||
|
||||
// Bot Configuration
|
||||
export const BOT_CONFIG = {
|
||||
name: '방장',
|
||||
id: 'ai-manager-bot',
|
||||
} as const;
|
||||
|
||||
// Storage Configuration
|
||||
export const STORAGE_CONFIG = {
|
||||
messagesKey: 'chat_messages',
|
||||
maxStoredMessages: 50,
|
||||
maxRecentMessages: 20,
|
||||
historyToSend: 20,
|
||||
aiContextMessages: 10,
|
||||
} as const;
|
||||
|
||||
// API Configuration
|
||||
export const API_CONFIG = {
|
||||
openaiTimeoutMs: 15000,
|
||||
weatherTimeoutMs: 5000,
|
||||
openaiModel: 'gpt-4o-mini',
|
||||
maxTokens: 500,
|
||||
temperature: 0.8,
|
||||
} as const;
|
||||
|
||||
// Delay Configuration (in milliseconds)
|
||||
export const DELAY_CONFIG = {
|
||||
botWelcome: 500,
|
||||
moderationWarning: 300,
|
||||
aiResponse: 800,
|
||||
} as const;
|
||||
|
||||
// AI Response Configuration
|
||||
export const AI_RESPONSE_CONFIG = {
|
||||
// Probability of AI responding to messages that don't explicitly mention it
|
||||
randomResponseProbability: 0.15,
|
||||
} as const;
|
||||
|
||||
// CORS Configuration
|
||||
export const CORS_CONFIG = {
|
||||
allowedOrigins: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3000',
|
||||
'https://chat.anvil.it.com',
|
||||
'https://anvil-chat.pages.dev',
|
||||
],
|
||||
allowedMethods: 'GET, POST, OPTIONS',
|
||||
allowedHeaders: 'Content-Type',
|
||||
} as const;
|
||||
|
||||
// City Coordinates for Weather API
|
||||
export const CITY_COORDINATES: Record<string, { lat: number; lon: number; name: string }> = {
|
||||
'seoul': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'서울': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'busan': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'부산': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'tokyo': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'도쿄': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'osaka': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'오사카': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'newyork': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'뉴욕': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'london': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'런던': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'default': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
};
|
||||
|
||||
// Weather Code Translations
|
||||
export const WEATHER_CODES: Record<number, string> = {
|
||||
0: '맑음 ☀️',
|
||||
1: '대체로 맑음 🌤️',
|
||||
2: '부분적 흐림 ⛅',
|
||||
3: '흐림 ☁️',
|
||||
45: '안개 🌫️',
|
||||
48: '짙은 안개 🌫️',
|
||||
51: '가벼운 이슬비 🌧️',
|
||||
53: '이슬비 🌧️',
|
||||
55: '강한 이슬비 🌧️',
|
||||
61: '가벼운 비 🌧️',
|
||||
63: '비 🌧️',
|
||||
65: '강한 비 🌧️',
|
||||
71: '가벼운 눈 🌨️',
|
||||
73: '눈 🌨️',
|
||||
75: '폭설 ❄️',
|
||||
77: '싸락눈 🌨️',
|
||||
80: '소나기 🌧️',
|
||||
81: '강한 소나기 🌧️',
|
||||
82: '폭우 ⛈️',
|
||||
85: '눈소나기 🌨️',
|
||||
86: '강한 눈소나기 🌨️',
|
||||
95: '뇌우 ⛈️',
|
||||
96: '우박 동반 뇌우 ⛈️',
|
||||
99: '강한 우박 동반 뇌우 ⛈️',
|
||||
};
|
||||
137
worker/src/index.ts
Normal file
137
worker/src/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ChatRoom } from './ChatRoom';
|
||||
|
||||
export { ChatRoom };
|
||||
|
||||
export interface Env {
|
||||
CHAT_ROOM: DurableObjectNamespace;
|
||||
OPENAI_API_KEY: string;
|
||||
ENVIRONMENT: string;
|
||||
}
|
||||
|
||||
// Allowed origins for CORS
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://chat.anvil.it.com',
|
||||
'https://chat-frontend-4wf.pages.dev',
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3000',
|
||||
];
|
||||
|
||||
function getCorsHeaders(origin: string | null): Record<string, string> {
|
||||
// Only allow exact matches or specific Pages project subdomains
|
||||
const isAllowed = origin && ALLOWED_ORIGINS.some(allowed => {
|
||||
if (origin === allowed) return true;
|
||||
// Only allow subdomains of our specific Pages project
|
||||
if (allowed.includes('.pages.dev')) {
|
||||
const projectDomain = allowed.replace('https://', '');
|
||||
// Match: xxx.chat-frontend-4wf.pages.dev (preview deployments)
|
||||
return origin.endsWith(projectDomain) ||
|
||||
origin.match(/^https:\/\/[a-f0-9]+\.chat-frontend-4wf\.pages\.dev$/);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const allowedOrigin = isAllowed ? origin : ALLOWED_ORIGINS[0];
|
||||
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
// Security headers
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': "default-src 'self'; connect-src 'self' wss: https:; style-src 'self' 'unsafe-inline'; script-src 'self'",
|
||||
};
|
||||
}
|
||||
|
||||
function handleCors(request: Request): Response | null {
|
||||
if (request.method === 'OPTIONS') {
|
||||
const origin = request.headers.get('Origin');
|
||||
return new Response(null, { headers: getCorsHeaders(origin) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addCorsHeaders(response: Response, request: Request): Response {
|
||||
const origin = request.headers.get('Origin');
|
||||
const newHeaders = new Headers(response.headers);
|
||||
Object.entries(getCorsHeaders(origin)).forEach(([key, value]) => {
|
||||
newHeaders.set(key, value);
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Handle CORS preflight
|
||||
const corsResponse = handleCors(request);
|
||||
if (corsResponse) return corsResponse;
|
||||
|
||||
try {
|
||||
// Route: GET /api/rooms/:roomId/websocket
|
||||
const wsMatch = path.match(/^\/api\/rooms\/([^/]+)\/websocket$/);
|
||||
if (wsMatch) {
|
||||
const roomId = wsMatch[1];
|
||||
return await handleWebSocketUpgrade(request, env, roomId);
|
||||
}
|
||||
|
||||
// Route: GET /api/rooms/:roomId/users
|
||||
const usersMatch = path.match(/^\/api\/rooms\/([^/]+)\/users$/);
|
||||
if (usersMatch) {
|
||||
const roomId = usersMatch[1];
|
||||
return await handleGetUsers(request, env, roomId);
|
||||
}
|
||||
|
||||
// Route: GET /api/health
|
||||
if (path === '/api/health') {
|
||||
return addCorsHeaders(Response.json({ status: 'ok', timestamp: Date.now() }), request);
|
||||
}
|
||||
|
||||
// Route: GET / - Simple landing page
|
||||
if (path === '/') {
|
||||
return new Response('Chat API Server. Use /api/rooms/:roomId/websocket to connect.', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
return addCorsHeaders(new Response('Not Found', { status: 404 }), request);
|
||||
} catch (error) {
|
||||
console.error('Worker error:', error);
|
||||
return addCorsHeaders(Response.json({ error: 'Internal Server Error' }, { status: 500 }), request);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function handleWebSocketUpgrade(request: Request, env: Env, roomId: string): Promise<Response> {
|
||||
// Verify WebSocket upgrade request
|
||||
if (request.headers.get('Upgrade') !== 'websocket') {
|
||||
return new Response('Expected WebSocket upgrade', { status: 426 });
|
||||
}
|
||||
|
||||
// Get or create the Durable Object for this room
|
||||
const id = env.CHAT_ROOM.idFromName(roomId);
|
||||
const stub = env.CHAT_ROOM.get(id);
|
||||
|
||||
// Forward the request to the Durable Object
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
async function handleGetUsers(request: Request, env: Env, roomId: string): Promise<Response> {
|
||||
const id = env.CHAT_ROOM.idFromName(roomId);
|
||||
const stub = env.CHAT_ROOM.get(id);
|
||||
|
||||
const usersUrl = new URL(request.url);
|
||||
usersUrl.pathname = '/users';
|
||||
|
||||
const response = await stub.fetch(new Request(usersUrl.toString()));
|
||||
return addCorsHeaders(response, request);
|
||||
}
|
||||
84
worker/src/logger.ts
Normal file
84
worker/src/logger.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Environment-aware logging utility
|
||||
* Reduces noise in production while preserving debug info in development
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LoggerConfig {
|
||||
level: LogLevel;
|
||||
isDevelopment: boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
class Logger {
|
||||
private config: LoggerConfig;
|
||||
|
||||
constructor(isDevelopment: boolean = false) {
|
||||
this.config = {
|
||||
level: isDevelopment ? 'debug' : 'warn',
|
||||
isDevelopment,
|
||||
};
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.level];
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, message: string, data?: unknown): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
if (data !== undefined) {
|
||||
return `${prefix} ${message} ${JSON.stringify(data)}`;
|
||||
}
|
||||
return `${prefix} ${message}`;
|
||||
}
|
||||
|
||||
debug(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.log(this.formatMessage('debug', message, data));
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('info')) {
|
||||
console.log(this.formatMessage('info', message, data));
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
console.warn(this.formatMessage('warn', message, data));
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('error')) {
|
||||
console.error(this.formatMessage('error', message, data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance - will be configured based on environment
|
||||
let loggerInstance: Logger | null = null;
|
||||
|
||||
export function getLogger(isDevelopment?: boolean): Logger {
|
||||
if (!loggerInstance) {
|
||||
// Default to production mode if not specified
|
||||
loggerInstance = new Logger(isDevelopment ?? false);
|
||||
}
|
||||
return loggerInstance;
|
||||
}
|
||||
|
||||
export function initLogger(isDevelopment: boolean): Logger {
|
||||
loggerInstance = new Logger(isDevelopment);
|
||||
return loggerInstance;
|
||||
}
|
||||
|
||||
export { Logger };
|
||||
34
worker/src/validation.ts
Normal file
34
worker/src/validation.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// WebSocket incoming message schemas
|
||||
export const MessageSchema = z.object({
|
||||
type: z.literal('message'),
|
||||
content: z.string().min(1).max(2000).trim(),
|
||||
});
|
||||
|
||||
export const RenameSchema = z.object({
|
||||
type: z.literal('rename'),
|
||||
name: z.string().min(1).max(50).trim(),
|
||||
});
|
||||
|
||||
export const WebSocketMessageSchema = z.discriminatedUnion('type', [
|
||||
MessageSchema,
|
||||
RenameSchema,
|
||||
]);
|
||||
|
||||
// Room ID validation
|
||||
export const RoomIdSchema = z.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Room ID must be alphanumeric with dashes and underscores only');
|
||||
|
||||
// User name validation
|
||||
export const UserNameSchema = z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.trim();
|
||||
|
||||
// Types
|
||||
export type WebSocketMessage = z.infer<typeof WebSocketMessageSchema>;
|
||||
export type MessageData = z.infer<typeof MessageSchema>;
|
||||
export type RenameData = z.infer<typeof RenameSchema>;
|
||||
15
worker/tsconfig.json
Normal file
15
worker/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
18
worker/wrangler.toml
Normal file
18
worker/wrangler.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
name = "chat-worker"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
|
||||
]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["ChatRoom"]
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "development"
|
||||
|
||||
# OpenAI API key is stored as a secret
|
||||
# Run: wrangler secret put OPENAI_API_KEY
|
||||
Reference in New Issue
Block a user