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:
kappa
2026-01-19 10:17:27 +09:00
commit 554c578345
38 changed files with 10608 additions and 0 deletions

26
.gitignore vendored Normal file
View 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
View 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
View 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
View 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`
**증상**: `&#106;avascript:` 같은 인코딩된 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 테스트**: `&#106;avascript:alert(1)` 같은 인코딩된 URL 차단 확인
3. **메모리 테스트**: 장시간 채팅 후 브라우저 메모리 사용량 확인
4. **Storage 테스트**: DO storage 일시적 실패 시 retry 동작 확인
5. **동시성 테스트**: 동일 사용자 동시 요청 시 silence 체크 정상 동작 확인

13
frontend/index.html Normal file
View 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
View 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
View 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
View 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
View 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;

View 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;
}
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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
View 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);
},
};

View 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
View 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
View 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>
);

View 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, '&lt;').replace(/>/g, '&gt;');
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 &#106;avascript:
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
View 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
View 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" }]
}

View 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
View 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

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View 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
View 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
View 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}&current=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
View 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);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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