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

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