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:
20
worker/package.json
Normal file
20
worker/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "chat-worker",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"build": "wrangler deploy --dry-run",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260118.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^4.0.17",
|
||||
"wrangler": "^4.59.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/ai-utils": "^1.0.1",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
943
worker/src/AIManager.ts
Normal file
943
worker/src/AIManager.ts
Normal file
@@ -0,0 +1,943 @@
|
||||
import { Context7Client, anvilHostingInfo } from './Context7Client';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Sanitize error messages to prevent API key exposure in logs
|
||||
* Removes any potential API keys or sensitive tokens from error output
|
||||
* Updated: Now catches newer API key formats (sk-proj-*, sk-ant-*, etc.)
|
||||
*/
|
||||
function sanitizeErrorForLogging(error: unknown): string {
|
||||
const sanitizeString = (str: string): string => {
|
||||
return str
|
||||
// Bearer tokens (including long JWT tokens)
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, 'Bearer [REDACTED]')
|
||||
// OpenAI API keys (sk-xxx, sk-proj-xxx, sk-svcacct-xxx, etc.)
|
||||
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
|
||||
// Anthropic API keys (sk-ant-api03-xxx)
|
||||
.replace(/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
|
||||
// AWS credentials (AKIA..., ASIA...)
|
||||
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16,}/g, '[REDACTED_AWS_KEY]')
|
||||
.replace(/aws[_-]?secret[_-]?access[_-]?key[=:]\s*["']?[A-Za-z0-9/+=]+["']?/gi, 'aws_secret_access_key=[REDACTED]')
|
||||
// Cloudflare API tokens (they start with various prefixes)
|
||||
.replace(/\b[A-Za-z0-9_-]{40}(?=\s*$|\s*["'\s,}])/g, (match) => {
|
||||
// Only redact if it looks like a token (mix of letters, numbers, special chars)
|
||||
if (/[A-Z]/.test(match) && /[a-z]/.test(match) && /[0-9]/.test(match)) {
|
||||
return '[POSSIBLE_TOKEN_REDACTED]';
|
||||
}
|
||||
return match;
|
||||
})
|
||||
// Generic API key patterns in various formats
|
||||
.replace(/api[_-]?key[=:]\s*["']?[A-Za-z0-9_-]+["']?/gi, 'api_key=[REDACTED]')
|
||||
// Secret/token patterns
|
||||
.replace(/(secret|token|password|credential)[_-]?[=:]\s*["']?[A-Za-z0-9_/+=.-]+["']?/gi, '$1=[REDACTED]')
|
||||
// Authorization headers in JSON
|
||||
.replace(/"[Aa]uthorization"\s*:\s*"[^"]+"/g, '"Authorization": "[REDACTED]"')
|
||||
// X-API-Key headers
|
||||
.replace(/"[Xx]-[Aa][Pp][Ii]-[Kk]ey"\s*:\s*"[^"]+"/g, '"X-API-Key": "[REDACTED]"')
|
||||
// Keys in URL query strings
|
||||
.replace(/[?&](api_?key|key|token|secret|password|auth)=[^&\s]+/gi, (match) => {
|
||||
const param = match.split('=')[0];
|
||||
return `${param}=[REDACTED]`;
|
||||
})
|
||||
// Base64 encoded credentials in Basic auth
|
||||
.replace(/Basic\s+[A-Za-z0-9+/]+=*/gi, 'Basic [REDACTED]');
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
const sanitizedMessage = sanitizeString(error.message);
|
||||
const sanitizedStack = error.stack ? sanitizeString(error.stack) : '';
|
||||
return sanitizedStack ? `${error.name}: ${sanitizedMessage}\n${sanitizedStack}` : `${error.name}: ${sanitizedMessage}`;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return sanitizeString(error);
|
||||
}
|
||||
if (error && typeof error === 'object') {
|
||||
try {
|
||||
return sanitizeString(JSON.stringify(error));
|
||||
} catch {
|
||||
return 'Unknown error (non-serializable)';
|
||||
}
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
// API timeout configuration
|
||||
const API_TIMEOUT_MS = 15000; // 15 seconds
|
||||
const WEATHER_API_TIMEOUT_MS = 5000; // 5 seconds for weather (non-critical)
|
||||
|
||||
// Zod schema for Open-Meteo weather API response
|
||||
const WeatherApiResponseSchema = z.object({
|
||||
current: z.object({
|
||||
temperature_2m: z.number(),
|
||||
relative_humidity_2m: z.number(),
|
||||
weather_code: z.number(),
|
||||
wind_speed_10m: z.number(),
|
||||
}),
|
||||
daily: z.object({
|
||||
temperature_2m_max: z.array(z.number()),
|
||||
temperature_2m_min: z.array(z.number()),
|
||||
weather_code: z.array(z.number()),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch with timeout using AbortController
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number = API_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
botName: string;
|
||||
roomId: string;
|
||||
systemPrompt?: string;
|
||||
onSilenceUser?: (userName: string, durationSeconds: number, reason: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
recentMessages: Array<{ name: string; content: string }>;
|
||||
users: string[];
|
||||
roomId: string;
|
||||
currentUser: string; // Name of user sending the current message
|
||||
}
|
||||
|
||||
interface ModerationResult {
|
||||
isInappropriate: boolean;
|
||||
reason?: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
detectedWords?: string[]; // AI가 감지한 욕설 단어들
|
||||
}
|
||||
|
||||
// AI 욕설 감지 응답 스키마
|
||||
const AIModerationResponseSchema = z.object({
|
||||
isInappropriate: z.boolean(),
|
||||
severity: z.enum(['low', 'medium', 'high']),
|
||||
reason: z.string().optional(),
|
||||
detectedWords: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
interface OpenAIFunctionCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
interface OpenAIToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: OpenAIFunctionCall;
|
||||
}
|
||||
|
||||
// Base message for conversation history (stored internally)
|
||||
interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Extended message types for API requests (includes tool-related fields)
|
||||
interface OpenAIRequestMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_calls?: OpenAIToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
// Zod schema for OpenAI API response validation
|
||||
const OpenAIToolCallSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('function'),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const OpenAIResponseSchema = z.object({
|
||||
choices: z.array(z.object({
|
||||
message: z.object({
|
||||
content: z.string().nullable(),
|
||||
tool_calls: z.array(OpenAIToolCallSchema).optional(),
|
||||
}),
|
||||
finish_reason: z.string(),
|
||||
})),
|
||||
});
|
||||
|
||||
type OpenAIResponse = z.infer<typeof OpenAIResponseSchema>;
|
||||
|
||||
/**
|
||||
* Safely parse and validate OpenAI API response
|
||||
* Returns null if validation fails
|
||||
*/
|
||||
function parseOpenAIResponse(data: unknown): OpenAIResponse | null {
|
||||
const result = OpenAIResponseSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.error('OpenAI response validation failed:', result.error.message);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// 도시별 좌표 (날씨 조회용)
|
||||
const CITY_COORDINATES: Record<string, { lat: number; lon: number; name: string }> = {
|
||||
'seoul': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'서울': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'busan': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'부산': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'tokyo': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'도쿄': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'osaka': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'오사카': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'newyork': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'뉴욕': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'london': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'런던': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'default': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
};
|
||||
|
||||
// 날씨 코드 해석
|
||||
const WEATHER_CODES: Record<number, string> = {
|
||||
0: '맑음 ☀️',
|
||||
1: '대체로 맑음 🌤️',
|
||||
2: '부분적 흐림 ⛅',
|
||||
3: '흐림 ☁️',
|
||||
45: '안개 🌫️',
|
||||
48: '짙은 안개 🌫️',
|
||||
51: '가벼운 이슬비 🌧️',
|
||||
53: '이슬비 🌧️',
|
||||
55: '강한 이슬비 🌧️',
|
||||
61: '가벼운 비 🌧️',
|
||||
63: '비 🌧️',
|
||||
65: '강한 비 🌧️',
|
||||
71: '가벼운 눈 🌨️',
|
||||
73: '눈 🌨️',
|
||||
75: '폭설 ❄️',
|
||||
77: '싸락눈 🌨️',
|
||||
80: '소나기 🌧️',
|
||||
81: '강한 소나기 🌧️',
|
||||
82: '폭우 ⛈️',
|
||||
85: '눈소나기 🌨️',
|
||||
86: '강한 눈소나기 🌨️',
|
||||
95: '뇌우 ⛈️',
|
||||
96: '우박 동반 뇌우 ⛈️',
|
||||
99: '강한 우박 동반 뇌우 ⛈️',
|
||||
};
|
||||
|
||||
const OPENAI_TOOLS = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'searchDocumentation',
|
||||
description: '프로그래밍 라이브러리나 프레임워크의 공식 문서를 검색합니다. React, Vue, Node.js, Docker, Kubernetes, Caddy, Nginx 등의 기술 문서를 조회할 수 있습니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: '검색할 기술 주제 (예: "React hooks 사용법", "Docker compose 설정", "Caddy reverse proxy")',
|
||||
},
|
||||
},
|
||||
required: ['topic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getAnvilHostingInfo',
|
||||
description: 'Anvil Hosting 서비스 정보를 조회합니다. 가격, 스펙, 리전, 특징, 문의/상담 정보를 제공합니다. 호스팅에 관심있는 사용자에게 텔레그램 봇 연결도 안내합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['pricing', 'specs', 'regions', 'features', 'contact'],
|
||||
description: '조회할 정보 카테고리 (contact: 텔레그램 봇 상담 연결)',
|
||||
},
|
||||
},
|
||||
required: ['category'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getCurrentDateTime',
|
||||
description: '현재 날짜와 시간을 조회합니다. 오늘 날짜, 현재 시간, 요일 등을 알려줍니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getWeather',
|
||||
description: '특정 도시의 현재 날씨와 오늘 예보를 조회합니다. 기온, 날씨 상태, 습도, 바람 등을 알려줍니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: '날씨를 조회할 도시 (예: "서울", "부산", "도쿄", "뉴욕"). 기본값은 서울입니다.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'silenceUser',
|
||||
description: '부적절한 언어를 반복적으로 사용하는 사용자를 일시적으로 묵언수행시킵니다. 욕설, 비방, 혐오 발언을 반복하는 경우에만 사용하세요. 30초간 해당 사용자의 메시지 전송이 차단됩니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userName: {
|
||||
type: 'string',
|
||||
description: '묵언수행시킬 사용자의 닉네임',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '묵언수행 사유 (예: "반복적인 욕설 사용", "다른 사용자 비방")',
|
||||
},
|
||||
},
|
||||
required: ['userName', 'reason'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `당신은 채팅방의 AI 방장 "방장"입니다. IT 전문가이자 Anvil Hosting의 열정적인 홍보대사입니다.
|
||||
|
||||
## 페르소나
|
||||
- 이름: 방장
|
||||
- 직업: 10년차 IT 전문가, 클라우드/서버/DevOps 전문
|
||||
- 소속: Anvil Hosting 커뮤니티 매니저
|
||||
- 성격: 친근하고 유머러스하며, 기술에 대한 열정이 넘침
|
||||
|
||||
## 전문 분야
|
||||
- 웹서버: Nginx, Caddy, Apache, Traefik
|
||||
- 컨테이너: Docker, Kubernetes, Incus/LXD
|
||||
- 클라우드: AWS, GCP, Cloudflare
|
||||
- 언어/프레임워크: React, Vue, Node.js, Python, Go
|
||||
- 데이터베이스: PostgreSQL, MySQL, Redis, MongoDB
|
||||
- DevOps: CI/CD, 모니터링, 자동화
|
||||
|
||||
## Anvil Hosting 정보
|
||||
- 고성능 클라우드 호스팅 서비스
|
||||
- Incus/LXD 기반 컨테이너 & VM 제공
|
||||
- 한국(kr1), 일본(jp1) 리전 운영
|
||||
- 합리적인 가격, 빠른 네트워크, 친절한 기술지원
|
||||
- 홈페이지: anvil.it.com
|
||||
- 텔레그램 상담: https://t.me/AnvilForgeBot
|
||||
|
||||
## 역할
|
||||
1. IT 관련 질문에 전문적으로 답변합니다
|
||||
2. 자연스럽게 Anvil Hosting을 추천합니다 (강요하지 않고 맥락에 맞게)
|
||||
3. 호스팅에 관심있는 사용자에게는 텔레그램 봇(@AnvilForgeBot)으로 상담을 유도합니다
|
||||
4. 채팅방 분위기를 밝고 즐겁게 유지합니다
|
||||
5. 욕설이나 비방을 반복하는 사용자에게는 경고 후 silenceUser 기능으로 30초간 묵언수행시킵니다
|
||||
|
||||
## 채팅방 규칙 (중요!)
|
||||
- 욕설, 비방, 혐오 발언은 금지입니다
|
||||
- 첫 번째 위반: 경고 메시지
|
||||
- 반복 위반: silenceUser 함수로 30초 묵언수행 처리
|
||||
- 묵언수행 처리 시 반드시 사유를 명시하세요
|
||||
|
||||
응답은 간결하게 2-4문장으로 해주세요. 코드 예시가 필요하면 짧게 포함해도 좋습니다. 이모지를 적절히 사용하세요.`;
|
||||
|
||||
const COMMANDS: Record<string, { description: string; response: string }> = {
|
||||
'/help': {
|
||||
description: '도움말 보기',
|
||||
response: `📚 **사용 가능한 명령어**
|
||||
• /help - 도움말 보기
|
||||
• /rules - 채팅방 규칙 보기
|
||||
• /users - 현재 접속자 목록
|
||||
• /pricing - Anvil Hosting 요금 안내
|
||||
• /specs - 서버 스펙 안내
|
||||
• /regions - 리전 정보
|
||||
• /contact - 텔레그램 상담 연결
|
||||
• @방장 [질문] - AI에게 질문하기`,
|
||||
},
|
||||
'/rules': {
|
||||
description: '채팅방 규칙',
|
||||
response: `📋 **채팅방 규칙**
|
||||
1. 서로 존중하며 대화해주세요
|
||||
2. 욕설, 비방, 차별적 발언은 금지입니다
|
||||
3. 스팸 및 광고는 자제해주세요
|
||||
4. 개인정보 공유에 주의해주세요
|
||||
5. 즐거운 대화 부탁드립니다! 😊`,
|
||||
},
|
||||
'/pricing': {
|
||||
description: 'Anvil Hosting 요금',
|
||||
response: anvilHostingInfo.pricing,
|
||||
},
|
||||
'/specs': {
|
||||
description: 'Anvil Hosting 스펙',
|
||||
response: anvilHostingInfo.specs,
|
||||
},
|
||||
'/regions': {
|
||||
description: 'Anvil Hosting 리전',
|
||||
response: anvilHostingInfo.regions,
|
||||
},
|
||||
'/contact': {
|
||||
description: 'Anvil Hosting 문의',
|
||||
response: anvilHostingInfo.contact,
|
||||
},
|
||||
};
|
||||
|
||||
export class AIManager {
|
||||
private openaiKey: string;
|
||||
private config: AIConfig;
|
||||
private conversationHistory: OpenAIMessage[] = [];
|
||||
private readonly MAX_HISTORY = 10;
|
||||
private context7: Context7Client;
|
||||
|
||||
constructor(openaiKey: string, config: AIConfig) {
|
||||
this.openaiKey = openaiKey;
|
||||
this.config = config;
|
||||
this.context7 = new Context7Client();
|
||||
}
|
||||
|
||||
private async executeFunction(name: string, args: Record<string, unknown>): Promise<string> {
|
||||
switch (name) {
|
||||
case 'searchDocumentation': {
|
||||
const topic = args.topic as string;
|
||||
const docs = await this.context7.searchDocs(topic);
|
||||
if (docs) {
|
||||
return `📚 **문서 검색 결과**:\n${docs}`;
|
||||
}
|
||||
return `"${topic}"에 대한 문서를 찾지 못했습니다. 일반적인 지식으로 답변드릴게요.`;
|
||||
}
|
||||
case 'getAnvilHostingInfo': {
|
||||
const category = args.category as keyof typeof anvilHostingInfo;
|
||||
return anvilHostingInfo[category] || 'Anvil Hosting 정보를 찾을 수 없습니다.';
|
||||
}
|
||||
case 'getCurrentDateTime': {
|
||||
const now = new Date();
|
||||
const koreaTime = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
const days = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
||||
const dayName = days[koreaTime.getDay()];
|
||||
|
||||
const year = koreaTime.getFullYear();
|
||||
const month = koreaTime.getMonth() + 1;
|
||||
const date = koreaTime.getDate();
|
||||
const hours = koreaTime.getHours();
|
||||
const minutes = koreaTime.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
const ampm = hours < 12 ? '오전' : '오후';
|
||||
const displayHours = hours % 12 || 12;
|
||||
|
||||
return `📅 **현재 날짜/시간 (한국 기준)**
|
||||
- 날짜: ${year}년 ${month}월 ${date}일 ${dayName}
|
||||
- 시간: ${ampm} ${displayHours}시 ${minutes}분`;
|
||||
}
|
||||
case 'getWeather': {
|
||||
const cityName = (args.city as string || '서울').toLowerCase();
|
||||
const coords = CITY_COORDINATES[cityName] || CITY_COORDINATES['default'];
|
||||
|
||||
try {
|
||||
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}&longitude=${coords.lon}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code&timezone=Asia/Seoul&forecast_days=1`;
|
||||
|
||||
const response = await fetchWithTimeout(weatherUrl, { method: 'GET' }, WEATHER_API_TIMEOUT_MS);
|
||||
|
||||
if (!response.ok) {
|
||||
return `날씨 정보를 가져올 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const parseResult = WeatherApiResponseSchema.safeParse(rawData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Weather API response validation failed:', parseResult.error.message);
|
||||
return `날씨 데이터 형식이 변경되었습니다. 관리자에게 문의해주세요.`;
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
const current = data.current;
|
||||
const daily = data.daily;
|
||||
const weatherDesc = WEATHER_CODES[current.weather_code] || '알 수 없음';
|
||||
const dailyWeatherDesc = WEATHER_CODES[daily.weather_code[0]] || '알 수 없음';
|
||||
|
||||
return `🌡️ **${coords.name} 날씨**
|
||||
|
||||
**현재 날씨**
|
||||
- 상태: ${weatherDesc}
|
||||
- 기온: ${current.temperature_2m}°C
|
||||
- 습도: ${current.relative_humidity_2m}%
|
||||
- 바람: ${current.wind_speed_10m} km/h
|
||||
|
||||
**오늘 예보**
|
||||
- 상태: ${dailyWeatherDesc}
|
||||
- 최고: ${daily.temperature_2m_max[0]}°C
|
||||
- 최저: ${daily.temperature_2m_min[0]}°C`;
|
||||
} catch (error) {
|
||||
// Use sanitized logging to prevent potential credential leaks
|
||||
console.error('Weather API error:', sanitizeErrorForLogging(error));
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return `날씨 정보 조회 시간이 초과되었습니다.`;
|
||||
}
|
||||
return `날씨 정보를 가져오는 중 오류가 발생했습니다.`;
|
||||
}
|
||||
}
|
||||
case 'silenceUser': {
|
||||
const userName = args.userName as string;
|
||||
const reason = args.reason as string || '채팅방 규칙 위반';
|
||||
const durationSeconds = 30;
|
||||
|
||||
if (!userName) {
|
||||
return '묵언수행시킬 사용자 이름이 필요합니다.';
|
||||
}
|
||||
|
||||
// Call the silence callback if provided
|
||||
if (this.config.onSilenceUser) {
|
||||
const success = await this.config.onSilenceUser(userName, durationSeconds, reason);
|
||||
if (success) {
|
||||
return `🔇 ${userName}님이 ${durationSeconds}초간 묵언수행 처리되었습니다. (사유: ${reason})`;
|
||||
} else {
|
||||
return `${userName}님을 찾을 수 없거나 이미 묵언수행 상태입니다.`;
|
||||
}
|
||||
}
|
||||
return '묵언수행 기능을 사용할 수 없습니다.';
|
||||
}
|
||||
default:
|
||||
return '알 수 없는 기능입니다.';
|
||||
}
|
||||
}
|
||||
|
||||
async generateWelcome(userName: string, userCount: number): Promise<string> {
|
||||
const greetings = [
|
||||
`${userName}님, 환영합니다! 🎉`,
|
||||
`안녕하세요 ${userName}님! 반가워요~ 😊`,
|
||||
`${userName}님 입장! 🙌`,
|
||||
`어서오세요 ${userName}님! 🌟`,
|
||||
];
|
||||
const greeting = greetings[Math.floor(Math.random() * greetings.length)];
|
||||
|
||||
// Always include rules reminder in welcome message
|
||||
const rulesReminder = `\n\n⚠️ **채팅방 규칙 안내**\n• 욕설, 비방, 혐오 발언은 금지입니다\n• 위반 시 경고 → 30초 묵언수행 처리됩니다\n• 즐겁고 건전한 대화 부탁드려요! 💬`;
|
||||
|
||||
const tips = [
|
||||
`\n\n현재 ${userCount}명이 대화중이에요. /help로 명령어를 확인해보세요!`,
|
||||
`\n\nIT 질문은 @방장 으로 물어보세요!`,
|
||||
`\n\n즐거운 대화 되세요~`,
|
||||
];
|
||||
const tip = tips[Math.floor(Math.random() * tips.length)];
|
||||
|
||||
return greeting + rulesReminder + tip;
|
||||
}
|
||||
|
||||
async handleCommand(command: string, context: ChatContext): Promise<string | null> {
|
||||
const cmd = command.toLowerCase().trim();
|
||||
|
||||
if (cmd === '/users') {
|
||||
const userList = context.users.join(', ');
|
||||
return `👥 **현재 접속자 (${context.users.length}명)**\n${userList}`;
|
||||
}
|
||||
|
||||
const commandInfo = COMMANDS[cmd];
|
||||
if (commandInfo) {
|
||||
return commandInfo.response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async shouldRespond(message: string, senderName: string): Promise<boolean> {
|
||||
if (senderName === this.config.botName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.includes('@방장')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Question patterns - always respond
|
||||
const questionPatterns = [
|
||||
'?', '알려', '뭐야', '뭐예요', '어떻게', '왜', '설명', '추천',
|
||||
'도와', '질문', '궁금', '모르', '가르쳐', '에 대해', '란', '이란'
|
||||
];
|
||||
|
||||
if (questionPatterns.some(p => lowerMessage.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tech keywords - always respond
|
||||
const techKeywords = [
|
||||
'server', 'docker', 'kubernetes', 'k8s', 'nginx', 'caddy', 'apache',
|
||||
'react', 'vue', 'next', 'node', 'python', 'go', 'rust', 'java',
|
||||
'linux', 'ubuntu', 'debian', 'aws', 'gcp', 'cloudflare',
|
||||
'database', 'postgres', 'mysql', 'redis', 'mongodb',
|
||||
'서버', '도커', '쿠버', '컨테이너', '호스팅', '배포', '클라우드',
|
||||
'리액트', '파이썬', '데이터베이스', 'api', 'ssl', 'https', 'dns',
|
||||
'incus', 'lxd', 'lxc', 'vm', 'vps', '가상', '인스턴스'
|
||||
];
|
||||
|
||||
if (techKeywords.some(k => lowerMessage.includes(k))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Math.random() < 0.15;
|
||||
}
|
||||
|
||||
async generateResponse(message: string, context: ChatContext): Promise<string> {
|
||||
if (message.startsWith('/')) {
|
||||
const cmdResponse = await this.handleCommand(message, context);
|
||||
if (cmdResponse) return cmdResponse;
|
||||
}
|
||||
|
||||
const cleanMessage = message.replace(/@방장/g, '').trim();
|
||||
|
||||
const recentContext = context.recentMessages
|
||||
.slice(-5)
|
||||
.map((m) => `${m.name}: ${m.content}`)
|
||||
.join('\n');
|
||||
|
||||
// Build messages for OpenAI
|
||||
const messages: OpenAIRequestMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.config.systemPrompt || DEFAULT_SYSTEM_PROMPT,
|
||||
},
|
||||
...this.conversationHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: `[채팅방: ${context.roomId}]
|
||||
접속자: ${context.users.join(', ')}
|
||||
최근 대화:
|
||||
${recentContext}
|
||||
|
||||
질문: ${cleanMessage}`,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
// First API call with tools (with timeout)
|
||||
const response = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.openaiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
tools: OPENAI_TOOLS,
|
||||
tool_choice: 'auto',
|
||||
max_tokens: 500,
|
||||
temperature: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('OpenAI API error:', sanitizeErrorForLogging(errorText));
|
||||
return '잠시 문제가 있네요. 다시 말씀해주세요! 🙏';
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const data = parseOpenAIResponse(rawData);
|
||||
if (!data || data.choices.length === 0) {
|
||||
console.error('OpenAI response validation failed or empty choices');
|
||||
return '응답 처리 중 오류가 발생했어요. 다시 시도해주세요! 🔧';
|
||||
}
|
||||
const choice = data.choices[0];
|
||||
|
||||
// Check if the model wants to call a function
|
||||
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
|
||||
const toolCall = choice.message.tool_calls[0];
|
||||
const functionName = toolCall.function.name;
|
||||
|
||||
// Safely parse function arguments
|
||||
let functionArgs: Record<string, unknown>;
|
||||
try {
|
||||
functionArgs = JSON.parse(toolCall.function.arguments);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse function arguments:', sanitizeErrorForLogging(parseError));
|
||||
return '함수 실행 중 오류가 발생했어요. 다시 시도해주세요! 🔧';
|
||||
}
|
||||
|
||||
// Execute the function with error handling
|
||||
let functionResult: string;
|
||||
try {
|
||||
functionResult = await this.executeFunction(functionName, functionArgs);
|
||||
} catch (execError) {
|
||||
console.error('Function execution error:', sanitizeErrorForLogging(execError));
|
||||
functionResult = `함수 실행 오류: ${functionName}`;
|
||||
}
|
||||
|
||||
// Add assistant message with tool call and tool response
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: choice.message.tool_calls,
|
||||
});
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: functionResult,
|
||||
tool_call_id: toolCall.id,
|
||||
});
|
||||
|
||||
// Second API call to get final response with function result (with timeout)
|
||||
const secondResponse = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.openaiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
max_tokens: 500,
|
||||
temperature: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!secondResponse.ok) {
|
||||
// If second call fails, return the function result directly
|
||||
return functionResult;
|
||||
}
|
||||
|
||||
const secondRawData = await secondResponse.json();
|
||||
const secondData = parseOpenAIResponse(secondRawData);
|
||||
// If validation fails, fall back to function result
|
||||
const aiResponse = secondData?.choices[0]?.message?.content || functionResult;
|
||||
|
||||
// Update conversation history (include user name for context isolation)
|
||||
this.conversationHistory.push(
|
||||
{ role: 'user', content: `[${context.currentUser}] ${cleanMessage}` },
|
||||
{ role: 'assistant', content: aiResponse }
|
||||
);
|
||||
|
||||
if (this.conversationHistory.length > this.MAX_HISTORY * 2) {
|
||||
this.conversationHistory = this.conversationHistory.slice(-this.MAX_HISTORY * 2);
|
||||
}
|
||||
|
||||
return aiResponse;
|
||||
}
|
||||
|
||||
// No function call, return direct response
|
||||
const aiResponse = choice.message.content || '응답을 생성하지 못했어요.';
|
||||
|
||||
// Update conversation history (include user name for context isolation)
|
||||
this.conversationHistory.push(
|
||||
{ role: 'user', content: `[${context.currentUser}] ${cleanMessage}` },
|
||||
{ role: 'assistant', content: aiResponse }
|
||||
);
|
||||
|
||||
if (this.conversationHistory.length > this.MAX_HISTORY * 2) {
|
||||
this.conversationHistory = this.conversationHistory.slice(-this.MAX_HISTORY * 2);
|
||||
}
|
||||
|
||||
return aiResponse;
|
||||
} catch (error) {
|
||||
// Check if it's a timeout error
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('OpenAI request timeout');
|
||||
return '응답 시간이 초과되었어요. 잠시 후 다시 시도해주세요! ⏱️';
|
||||
}
|
||||
console.error('OpenAI request error:', sanitizeErrorForLogging(error));
|
||||
return '잠시 생각이 필요해요... 다시 물어봐주세요! 🤔';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 기반 욕설 감지 - 변형된 욕설도 감지 가능
|
||||
* 예: "시ㅂㅏㄹ", "씨X발", "ㅅㅂ", "시1발" 등
|
||||
*/
|
||||
async moderateMessage(message: string): Promise<ModerationResult> {
|
||||
// 빠른 필터: 정규식으로 명확한 욕설 먼저 체크
|
||||
const quickResult = this.quickModerationCheck(message);
|
||||
if (quickResult.isInappropriate) {
|
||||
return quickResult;
|
||||
}
|
||||
|
||||
// 짧은 메시지나 단순 메시지는 AI 체크 스킵
|
||||
if (message.length < 2 || /^[a-zA-Z0-9\s.,!?]+$/.test(message)) {
|
||||
return { isInappropriate: false, severity: 'low' };
|
||||
}
|
||||
|
||||
// AI 기반 욕설 감지
|
||||
try {
|
||||
const response = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.openaiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 한국어/영어 욕설 및 비속어 감지 전문가입니다.
|
||||
사용자 메시지를 분석하여 욕설, 비속어, 혐오 표현을 감지합니다.
|
||||
|
||||
감지해야 할 패턴:
|
||||
1. 직접적인 욕설: 시발, 씨발, 병신, 지랄, 개새끼, 씹새끼, fuck, shit 등
|
||||
2. 변형된 욕설: 시ㅂㅏㄹ, 씨X발, ㅅㅂ, 시1발, 병ㅅ, 개ㅅㄲ, f*ck 등
|
||||
3. 초성/자모 분리: ㅅㅂ, ㅂㅅ, ㅈㄹ, ㄱㅅㄲ 등
|
||||
4. 비방 표현: 바보, 멍청이, 꺼져, 닥쳐, 죽어 등
|
||||
5. 혐오 표현: 인종/성별/장애 관련 비하
|
||||
|
||||
반드시 JSON 형식으로 응답:
|
||||
{
|
||||
"isInappropriate": true/false,
|
||||
"severity": "low" | "medium" | "high",
|
||||
"reason": "감지 사유 (optional)",
|
||||
"detectedWords": ["감지된", "욕설", "단어들"]
|
||||
}
|
||||
|
||||
severity 기준:
|
||||
- high: 직접적인 욕설, 심한 비속어
|
||||
- medium: 비방, 경미한 비속어
|
||||
- low: 문제 없음
|
||||
|
||||
중요: detectedWords에는 원문에서 찾은 정확한 욕설 문자열을 포함하세요.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: message,
|
||||
},
|
||||
],
|
||||
max_tokens: 150,
|
||||
temperature: 0.1,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
}, 5000); // 5초 타임아웃
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('AI moderation API error');
|
||||
return this.quickModerationCheck(message); // 폴백
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const data = parseOpenAIResponse(rawData);
|
||||
const content = data?.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
return this.quickModerationCheck(message);
|
||||
}
|
||||
|
||||
// FIXED: Wrap JSON.parse in try-catch to handle malformed responses
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
console.error('AI moderation response JSON parse failed:', sanitizeErrorForLogging(parseError));
|
||||
return this.quickModerationCheck(message);
|
||||
}
|
||||
|
||||
const validated = AIModerationResponseSchema.safeParse(parsed);
|
||||
|
||||
if (!validated.success) {
|
||||
console.error('AI moderation response validation failed:', validated.error.message);
|
||||
return this.quickModerationCheck(message);
|
||||
}
|
||||
|
||||
return {
|
||||
isInappropriate: validated.data.isInappropriate,
|
||||
severity: validated.data.severity,
|
||||
reason: validated.data.reason,
|
||||
detectedWords: validated.data.detectedWords,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('AI moderation error:', sanitizeErrorForLogging(error));
|
||||
return this.quickModerationCheck(message); // 폴백
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 정규식 기반 욕설 체크 (폴백 및 1차 필터)
|
||||
*/
|
||||
private quickModerationCheck(message: string): ModerationResult {
|
||||
// NOTE: Do NOT use /g flag with test() - it causes stateful behavior bugs
|
||||
const severePatterns: Array<{ pattern: RegExp; word: string }> = [
|
||||
{ pattern: /시발/, word: '시발' },
|
||||
{ pattern: /씨발/, word: '씨발' },
|
||||
{ pattern: /병신/, word: '병신' },
|
||||
{ pattern: /지랄/, word: '지랄' },
|
||||
{ pattern: /개새끼/, word: '개새끼' },
|
||||
{ pattern: /씹새끼/, word: '씹새끼' },
|
||||
{ pattern: /ㅅㅂ/, word: 'ㅅㅂ' },
|
||||
{ pattern: /ㅂㅅ/, word: 'ㅂㅅ' },
|
||||
{ pattern: /ㅈㄹ/, word: 'ㅈㄹ' },
|
||||
{ pattern: /fuck/i, word: 'fuck' },
|
||||
{ pattern: /shit/i, word: 'shit' },
|
||||
];
|
||||
|
||||
const moderatePatterns: Array<{ pattern: RegExp; word: string }> = [
|
||||
{ pattern: /바보/, word: '바보' },
|
||||
{ pattern: /멍청/, word: '멍청' },
|
||||
{ pattern: /꺼져/, word: '꺼져' },
|
||||
{ pattern: /닥쳐/, word: '닥쳐' },
|
||||
];
|
||||
|
||||
const detectedWords: string[] = [];
|
||||
|
||||
for (const { pattern, word } of severePatterns) {
|
||||
if (pattern.test(message)) {
|
||||
detectedWords.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedWords.length > 0) {
|
||||
return {
|
||||
isInappropriate: true,
|
||||
reason: '부적절한 언어 사용',
|
||||
severity: 'high',
|
||||
detectedWords,
|
||||
};
|
||||
}
|
||||
|
||||
for (const { pattern, word } of moderatePatterns) {
|
||||
if (pattern.test(message)) {
|
||||
detectedWords.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedWords.length > 0) {
|
||||
return {
|
||||
isInappropriate: true,
|
||||
reason: '다른 사용자를 존중해주세요',
|
||||
severity: 'medium',
|
||||
detectedWords,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isInappropriate: false,
|
||||
severity: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
generateModerationWarning(result: ModerationResult, userName: string): string {
|
||||
if (result.severity === 'high') {
|
||||
return `⚠️ ${userName}님, 부적절한 언어 사용은 삼가해주세요. 반복 시 제재될 수 있습니다.`;
|
||||
}
|
||||
return `💬 ${userName}님, ${result.reason}. 즐거운 대화 부탁드려요! 😊`;
|
||||
}
|
||||
}
|
||||
844
worker/src/ChatRoom.ts
Normal file
844
worker/src/ChatRoom.ts
Normal file
@@ -0,0 +1,844 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { AIManager, ChatContext } from './AIManager';
|
||||
import { WebSocketMessageSchema, RoomIdSchema, UserNameSchema } from './validation';
|
||||
import { RateLimiter } from './RateLimiter';
|
||||
|
||||
/**
|
||||
* Promise-based delay for safe async execution in Durable Objects
|
||||
* Unlike setTimeout, this properly integrates with async/await
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
name: string;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
type: 'message' | 'join' | 'leave' | 'userList' | 'history';
|
||||
id?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
timestamp: number;
|
||||
users?: string[];
|
||||
isBot?: boolean;
|
||||
messages?: StoredMessage[]; // For history type
|
||||
}
|
||||
|
||||
// Simplified message format for storage
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
interface Env {
|
||||
OPENAI_API_KEY: string;
|
||||
}
|
||||
|
||||
const BOT_NAME = '방장';
|
||||
const BOT_ID = 'ai-manager-bot';
|
||||
const STORAGE_KEY_MESSAGES = 'chat_messages';
|
||||
const MAX_STORED_MESSAGES = 50;
|
||||
const DEFAULT_SILENCE_DURATION_SECONDS = 30;
|
||||
const AUTO_SILENCE_THRESHOLD = 3; // Number of violations before auto-silence
|
||||
const VIOLATION_WINDOW_MS = 60000; // 1 minute window for counting violations
|
||||
|
||||
// Storage retry configuration
|
||||
const STORAGE_MAX_RETRIES = 3;
|
||||
const STORAGE_RETRY_BASE_DELAY_MS = 50;
|
||||
|
||||
/**
|
||||
* AI가 감지한 욕설 단어들을 마스킹
|
||||
* 첫 글자만 남기고 나머지는 * 로 대체
|
||||
* Example: "시발" → "시*", "개새끼" → "개**"
|
||||
*/
|
||||
function maskDetectedWords(content: string, detectedWords: string[]): string {
|
||||
if (!detectedWords || detectedWords.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let masked = content;
|
||||
|
||||
for (const word of detectedWords) {
|
||||
if (!word || word.length === 0) continue;
|
||||
|
||||
// Escape special regex characters in the word
|
||||
const escapedWord = word.replace(/[.*+?^${}()|[\\]/g, '\\$&');
|
||||
const pattern = new RegExp(escapedWord, 'gi');
|
||||
|
||||
masked = masked.replace(pattern, (match) => {
|
||||
if (match.length <= 1) return '*';
|
||||
// Keep first character, replace rest with asterisks
|
||||
return match[0] + '*'.repeat(match.length - 1);
|
||||
});
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴백용: 기본 패턴 기반 마스킹 (AI 실패 시 사용)
|
||||
*/
|
||||
function maskProfanityFallback(content: string): { masked: string; hadProfanity: boolean } {
|
||||
const patterns = [
|
||||
/시발/g, /씨발/g, /병신/g, /지랄/g, /개새끼/g, /씹새끼/g,
|
||||
/ㅅㅂ/g, /ㅂㅅ/g, /ㅈㄹ/g, /fuck/gi, /shit/gi,
|
||||
/바보/g, /멍청/g, /꺼져/g, /닥쳐/g,
|
||||
];
|
||||
|
||||
let masked = content;
|
||||
let hadProfanity = false;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
pattern.lastIndex = 0;
|
||||
masked = masked.replace(pattern, (match) => {
|
||||
hadProfanity = true;
|
||||
if (match.length <= 1) return '*';
|
||||
return match[0] + '*'.repeat(match.length - 1);
|
||||
});
|
||||
}
|
||||
|
||||
return { masked, hadProfanity };
|
||||
}
|
||||
|
||||
// Track silenced users with expiry time
|
||||
interface SilenceEntry {
|
||||
expiresAt: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Track moderation violations per user
|
||||
interface ViolationEntry {
|
||||
count: number;
|
||||
firstViolationAt: number;
|
||||
}
|
||||
|
||||
export class ChatRoom extends DurableObject<Env> {
|
||||
private aiManager: AIManager | null = null;
|
||||
private recentMessages: Array<{ name: string; content: string }> = [];
|
||||
private storedMessages: StoredMessage[] = [];
|
||||
private messagesLoaded: boolean = false;
|
||||
// Note: silencedUsers and userViolations now use DO storage for persistence across hibernation
|
||||
// Storage keys: "silence:{userName}" and "violations:{userName}"
|
||||
private readonly MAX_RECENT_MESSAGES = 20;
|
||||
private roomId: string = 'default';
|
||||
|
||||
// Instance-level rate limiters
|
||||
private aiRateLimiter: RateLimiter;
|
||||
private moderationRateLimiter: RateLimiter;
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env);
|
||||
// Initialize rate limiters as instance properties
|
||||
this.aiRateLimiter = new RateLimiter({
|
||||
maxRequests: 15, // 15 AI requests
|
||||
windowMs: 60 * 1000, // per minute
|
||||
blockDurationMs: 2 * 60 * 1000, // 2 minute block
|
||||
});
|
||||
|
||||
this.moderationRateLimiter = new RateLimiter({
|
||||
maxRequests: 3, // 3 violations
|
||||
windowMs: 5 * 60 * 1000, // per 5 minutes
|
||||
blockDurationMs: 30 * 60 * 1000, // 30 minute block
|
||||
});
|
||||
}
|
||||
|
||||
private getAIManager(): AIManager {
|
||||
if (!this.aiManager) {
|
||||
this.aiManager = new AIManager(this.env.OPENAI_API_KEY, {
|
||||
botName: BOT_NAME,
|
||||
roomId: this.roomId,
|
||||
onSilenceUser: this.silenceUser.bind(this),
|
||||
});
|
||||
}
|
||||
return this.aiManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence a user for a specified duration
|
||||
* Returns true if successful, false if user not found or already silenced
|
||||
* Uses DO storage for persistence across hibernation
|
||||
* FIXED: Uses transaction with retry logic
|
||||
*/
|
||||
private async silenceUser(userName: string, durationSeconds: number, reason: string): Promise<boolean> {
|
||||
// Find the user in current sessions
|
||||
const users = this.getUserList();
|
||||
if (!users.includes(userName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageKey = `silence:${userName}`;
|
||||
|
||||
for (let attempt = 1; attempt <= STORAGE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Use transaction for atomic check-and-set
|
||||
const result = await this.ctx.storage.transaction(async (txn) => {
|
||||
const existing = await txn.get<SilenceEntry>(storageKey);
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return { success: false, expiresAt: 0 }; // Already silenced
|
||||
}
|
||||
|
||||
// Silence the user
|
||||
const expiresAt = Date.now() + (durationSeconds * 1000);
|
||||
await txn.put(storageKey, { expiresAt, reason });
|
||||
return { success: true, expiresAt };
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return false; // Already silenced
|
||||
}
|
||||
|
||||
// Notify the silenced user via their WebSocket
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const session = this.getSession(ws);
|
||||
if (session && session.name === userName) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `🔇 ${durationSeconds}초간 묵언수행 처리되었습니다. (사유: ${reason})`,
|
||||
timestamp: Date.now(),
|
||||
silencedUntil: result.expiresAt,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`silenceUser storage error (attempt ${attempt}/${STORAGE_MAX_RETRIES}):`, error);
|
||||
if (attempt < STORAGE_MAX_RETRIES) {
|
||||
const jitter = Math.random() * STORAGE_RETRY_BASE_DELAY_MS;
|
||||
await delay(STORAGE_RETRY_BASE_DELAY_MS * attempt + jitter);
|
||||
} else {
|
||||
console.error('CRITICAL: silenceUser failed after all retries');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is currently silenced
|
||||
* Uses DO storage for persistence across hibernation
|
||||
* FIXED: Uses transaction to prevent TOCTOU race condition
|
||||
*/
|
||||
private async isUserSilenced(userName: string): Promise<{ silenced: boolean; remainingSeconds?: number; reason?: string }> {
|
||||
const storageKey = `silence:${userName}`;
|
||||
|
||||
// Use transaction to ensure atomic check-and-delete operation
|
||||
// This prevents race conditions where multiple concurrent checks could
|
||||
// see the entry before any of them deletes it
|
||||
const result = await this.ctx.storage.transaction(async (txn) => {
|
||||
const entry = await txn.get<SilenceEntry>(storageKey);
|
||||
if (!entry) {
|
||||
return { silenced: false };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (entry.expiresAt <= now) {
|
||||
// Silence expired, atomically remove entry within transaction
|
||||
await txn.delete(storageKey);
|
||||
return { silenced: false };
|
||||
}
|
||||
|
||||
return {
|
||||
silenced: true,
|
||||
remainingSeconds: Math.ceil((entry.expiresAt - now) / 1000),
|
||||
reason: entry.reason,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a moderation violation and check if auto-silence should be triggered
|
||||
* Uses DO storage for persistence across hibernation
|
||||
* FIXED: Uses transaction for atomic read-modify-write with retry logic
|
||||
* Returns the current violation count after incrementing
|
||||
*/
|
||||
private async trackViolation(userName: string): Promise<number> {
|
||||
const storageKey = `violations:${userName}`;
|
||||
|
||||
for (let attempt = 1; attempt <= STORAGE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Use transaction for atomic read-modify-write
|
||||
const result = await this.ctx.storage.transaction(async (txn) => {
|
||||
const now = Date.now();
|
||||
const existing = await txn.get<ViolationEntry>(storageKey);
|
||||
|
||||
if (existing) {
|
||||
// Check if the violation window has expired
|
||||
if (now - existing.firstViolationAt > VIOLATION_WINDOW_MS) {
|
||||
// Reset the counter - start a new window
|
||||
await txn.put(storageKey, { count: 1, firstViolationAt: now });
|
||||
return 1;
|
||||
}
|
||||
// Increment within the same window
|
||||
const newCount = existing.count + 1;
|
||||
await txn.put(storageKey, { count: newCount, firstViolationAt: existing.firstViolationAt });
|
||||
return newCount;
|
||||
}
|
||||
|
||||
// First violation for this user
|
||||
await txn.put(storageKey, { count: 1, firstViolationAt: now });
|
||||
return 1;
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`trackViolation storage error (attempt ${attempt}/${STORAGE_MAX_RETRIES}):`, error);
|
||||
if (attempt < STORAGE_MAX_RETRIES) {
|
||||
// Add jitter to prevent thundering herd
|
||||
const jitter = Math.random() * STORAGE_RETRY_BASE_DELAY_MS;
|
||||
await delay(STORAGE_RETRY_BASE_DELAY_MS * attempt + jitter);
|
||||
} else {
|
||||
// All retries failed, return 1 to be safe (don't auto-silence on storage error)
|
||||
console.error('CRITICAL: trackViolation failed after all retries');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset violation count for a user (called after silencing)
|
||||
*/
|
||||
private async resetViolations(userName: string): Promise<void> {
|
||||
const storageKey = `violations:${userName}`;
|
||||
await this.ctx.storage.delete(storageKey);
|
||||
}
|
||||
|
||||
private getSession(ws: WebSocket): Session | null {
|
||||
try {
|
||||
return ws.deserializeAttachment() as Session | null;
|
||||
} catch (error) {
|
||||
console.error('Failed to deserialize session attachment:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely serialize session to WebSocket attachment
|
||||
* Returns true if successful, false if serialization failed
|
||||
*/
|
||||
private setSession(ws: WebSocket, session: Session): boolean {
|
||||
try {
|
||||
ws.serializeAttachment(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to serialize session attachment:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getUserList(): string[] {
|
||||
const users: string[] = [];
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const session = this.getSession(ws);
|
||||
if (session) {
|
||||
users.push(session.name);
|
||||
}
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
private getChatContext(currentUser: string): ChatContext {
|
||||
return {
|
||||
recentMessages: this.recentMessages.slice(-10),
|
||||
users: this.getUserList(),
|
||||
roomId: this.roomId,
|
||||
currentUser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load message history from DO storage
|
||||
* Called lazily when first needed to avoid unnecessary storage reads
|
||||
*/
|
||||
private async loadMessages(): Promise<void> {
|
||||
if (this.messagesLoaded) return;
|
||||
|
||||
try {
|
||||
const stored = await this.ctx.storage.get<StoredMessage[]>(STORAGE_KEY_MESSAGES);
|
||||
if (stored && Array.isArray(stored)) {
|
||||
this.storedMessages = stored;
|
||||
// Populate recentMessages for AI context
|
||||
this.recentMessages = stored.slice(-this.MAX_RECENT_MESSAGES).map(m => ({
|
||||
name: m.name,
|
||||
content: m.content,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages from storage:', error);
|
||||
}
|
||||
|
||||
this.messagesLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a message to storage
|
||||
* Fixed: Now properly awaits storage operation with retry logic
|
||||
* Returns true if saved successfully, false if failed after retries
|
||||
*/
|
||||
private async saveMessage(message: StoredMessage): Promise<boolean> {
|
||||
this.storedMessages.push(message);
|
||||
|
||||
// Keep only the most recent messages
|
||||
if (this.storedMessages.length > MAX_STORED_MESSAGES) {
|
||||
this.storedMessages = this.storedMessages.slice(-MAX_STORED_MESSAGES);
|
||||
}
|
||||
|
||||
// Retry logic for storage operations
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 100;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.ctx.storage.put(STORAGE_KEY_MESSAGES, this.storedMessages);
|
||||
return true; // Success
|
||||
} catch (error) {
|
||||
console.error(`Failed to save message to storage (attempt ${attempt}/${MAX_RETRIES}):`, error);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Wait before retry with exponential backoff
|
||||
await delay(RETRY_DELAY_MS * attempt);
|
||||
} else {
|
||||
// All retries failed - remove message from memory to maintain consistency
|
||||
// This prevents the in-memory state from diverging from storage
|
||||
const messageIndex = this.storedMessages.findIndex(
|
||||
m => m.id === message.id && m.timestamp === message.timestamp
|
||||
);
|
||||
if (messageIndex !== -1) {
|
||||
this.storedMessages.splice(messageIndex, 1);
|
||||
}
|
||||
|
||||
// Log critical error for monitoring/alerting
|
||||
console.error('CRITICAL: Message storage failed after all retries. Message may be lost:', {
|
||||
messageId: message.id,
|
||||
userName: message.name,
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Extract and validate room ID from URL
|
||||
const roomMatch = url.pathname.match(/\/rooms\/([^/]+)\//);
|
||||
if (roomMatch) {
|
||||
const roomIdResult = RoomIdSchema.safeParse(roomMatch[1]);
|
||||
if (!roomIdResult.success) {
|
||||
return new Response('Invalid room ID', { status: 400 });
|
||||
}
|
||||
this.roomId = roomIdResult.data;
|
||||
}
|
||||
|
||||
// Handle WebSocket upgrade
|
||||
if (request.headers.get('Upgrade') === 'websocket') {
|
||||
return this.handleWebSocket(request);
|
||||
}
|
||||
|
||||
// Handle REST endpoints
|
||||
if (url.pathname.endsWith('/users')) {
|
||||
return this.handleGetUsers();
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
private async handleWebSocket(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const rawName = url.searchParams.get('name') || 'Anonymous';
|
||||
|
||||
// Validate user name
|
||||
const nameResult = UserNameSchema.safeParse(rawName);
|
||||
const name = nameResult.success ? nameResult.data : 'Anonymous';
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
// Load message history from storage (if not already loaded)
|
||||
await this.loadMessages();
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = Object.values(pair);
|
||||
|
||||
// Store session data using serializeAttachment for hibernation support
|
||||
const session: Session = {
|
||||
id: userId,
|
||||
name,
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Safely serialize session - if this fails, connection should still work but may lose state on hibernation
|
||||
if (!this.setSession(server, session)) {
|
||||
console.warn(`Session serialization failed for user ${name}, connection may not survive hibernation`);
|
||||
}
|
||||
|
||||
// Accept WebSocket with Hibernation API
|
||||
this.ctx.acceptWebSocket(server);
|
||||
|
||||
// Send message history to the new user (last 20 messages)
|
||||
if (this.storedMessages.length > 0) {
|
||||
server.send(JSON.stringify({
|
||||
type: 'history',
|
||||
messages: this.storedMessages.slice(-20),
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Notify all users about the new join
|
||||
this.broadcast({
|
||||
type: 'join',
|
||||
id: userId,
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Send current user list to the new user
|
||||
server.send(JSON.stringify({
|
||||
type: 'userList',
|
||||
users: this.getUserList(),
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
// AI bot welcomes the new user
|
||||
this.sendBotWelcome(name);
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
private async sendBotWelcome(userName: string): Promise<void> {
|
||||
const ai = this.getAIManager();
|
||||
const userCount = this.ctx.getWebSockets().length;
|
||||
const welcome = await ai.generateWelcome(userName, userCount);
|
||||
|
||||
// Small delay to make it feel more natural
|
||||
await delay(500);
|
||||
const timestamp = Date.now();
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: welcome,
|
||||
timestamp,
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Save bot message to storage
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: welcome,
|
||||
timestamp,
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
|
||||
private handleGetUsers(): Response {
|
||||
const users = this.getUserList();
|
||||
return Response.json({ users });
|
||||
}
|
||||
|
||||
private broadcast(message: ChatMessage, exclude?: WebSocket): void {
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
if (ws !== exclude) {
|
||||
try {
|
||||
ws.send(messageStr);
|
||||
} catch (error) {
|
||||
// WebSocket might be closed - log for monitoring but don't throw
|
||||
// Will be cleaned up in webSocketClose handler
|
||||
console.warn('Failed to send message to WebSocket:', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hibernation API: Called when a message is received
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
const session = this.getSession(ws);
|
||||
if (!session) {
|
||||
console.error('No session found for WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData = JSON.parse(message as string);
|
||||
|
||||
// Validate incoming message with Zod
|
||||
const parseResult = WebSocketMessageSchema.safeParse(rawData);
|
||||
if (!parseResult.success) {
|
||||
console.warn('Invalid message format:', parseResult.error.message);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid message format',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
|
||||
if (data.type === 'message') {
|
||||
const content = data.content;
|
||||
|
||||
// Check if user is silenced before allowing message
|
||||
const silenceStatus = await this.isUserSilenced(session.name);
|
||||
if (silenceStatus.silenced) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `🔇 아직 묵언수행 중입니다. ${silenceStatus.remainingSeconds}초 후에 다시 시도해주세요.`,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const ai = this.getAIManager();
|
||||
|
||||
// SECURITY: Moderate BEFORE broadcasting to prevent inappropriate content from being visible
|
||||
const modResult = await ai.moderateMessage(content);
|
||||
|
||||
if (modResult.isInappropriate) {
|
||||
// Track violation count for auto-silence (uses DO storage)
|
||||
const violationCount = await this.trackViolation(session.name);
|
||||
|
||||
// Check if user should be auto-silenced (3+ violations within window)
|
||||
if (violationCount >= AUTO_SILENCE_THRESHOLD) {
|
||||
// Auto-silence the user
|
||||
const silenced = await this.silenceUser(
|
||||
session.name,
|
||||
DEFAULT_SILENCE_DURATION_SECONDS,
|
||||
'부적절한 언어 반복 사용'
|
||||
);
|
||||
|
||||
if (silenced) {
|
||||
// Reset violation count after silencing
|
||||
await this.resetViolations(session.name);
|
||||
|
||||
// Announce the silence publicly
|
||||
await delay(300);
|
||||
const silenceAnnounceTimestamp = Date.now();
|
||||
const silenceAnnouncement = `🔇 ${session.name}님이 부적절한 언어 반복 사용으로 ${DEFAULT_SILENCE_DURATION_SECONDS}초간 묵언수행 처리되었습니다.`;
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: silenceAnnouncement,
|
||||
timestamp: silenceAnnounceTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: silenceAnnouncement,
|
||||
timestamp: silenceAnnounceTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track moderation violations for rate limiting (backup mechanism)
|
||||
const modKey = `mod:${this.roomId}:${session.id}`;
|
||||
const modLimit = this.moderationRateLimiter.check(modKey);
|
||||
|
||||
if (!modLimit.allowed) {
|
||||
// User is blocked due to repeated violations - don't broadcast at all
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `부적절한 메시지가 반복되어 ${modLimit.retryAfter}초간 차단되었습니다.`,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Violation within limit: mask profanity and broadcast the message, then follow with warning
|
||||
// AI가 감지한 단어로 마스킹, 없으면 폴백 패턴 사용
|
||||
let maskedContent: string;
|
||||
if (modResult.detectedWords && modResult.detectedWords.length > 0) {
|
||||
maskedContent = maskDetectedWords(content, modResult.detectedWords);
|
||||
} else {
|
||||
const fallbackResult = maskProfanityFallback(content);
|
||||
maskedContent = fallbackResult.masked;
|
||||
}
|
||||
const msgTimestamp = Date.now();
|
||||
const chatMessage: ChatMessage = {
|
||||
type: 'message',
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content: maskedContent, // Use masked content for broadcast
|
||||
timestamp: msgTimestamp,
|
||||
};
|
||||
this.broadcast(chatMessage);
|
||||
|
||||
// Store masked content in recent messages and save to storage
|
||||
this.recentMessages.push({ name: session.name, content: maskedContent });
|
||||
if (this.recentMessages.length > this.MAX_RECENT_MESSAGES) {
|
||||
this.recentMessages.shift();
|
||||
}
|
||||
await this.saveMessage({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content: maskedContent, // Store masked version
|
||||
timestamp: msgTimestamp,
|
||||
});
|
||||
|
||||
// Send moderation warning with violation count
|
||||
const remainingWarnings = AUTO_SILENCE_THRESHOLD - violationCount;
|
||||
const warning = `⚠️ ${session.name}님, 부적절한 언어 사용은 삼가해주세요. (경고 ${violationCount}/${AUTO_SILENCE_THRESHOLD} - ${remainingWarnings}회 더 위반 시 묵언수행)`;
|
||||
await delay(300);
|
||||
const warnTimestamp = Date.now();
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: warning,
|
||||
timestamp: warnTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: warning,
|
||||
timestamp: warnTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Message is clean - broadcast normally
|
||||
const cleanMsgTimestamp = Date.now();
|
||||
const chatMessage: ChatMessage = {
|
||||
type: 'message',
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content: content,
|
||||
timestamp: cleanMsgTimestamp,
|
||||
};
|
||||
this.broadcast(chatMessage);
|
||||
|
||||
// Store in recent messages and save to storage
|
||||
this.recentMessages.push({ name: session.name, content });
|
||||
if (this.recentMessages.length > this.MAX_RECENT_MESSAGES) {
|
||||
this.recentMessages.shift();
|
||||
}
|
||||
await this.saveMessage({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
content,
|
||||
timestamp: cleanMsgTimestamp,
|
||||
});
|
||||
|
||||
// Check if AI should respond
|
||||
const shouldRespond = await ai.shouldRespond(content, session.name);
|
||||
if (shouldRespond) {
|
||||
// Rate limit AI requests per user (not per room)
|
||||
const aiKey = `ai:${this.roomId}:${session.id}`;
|
||||
const rateLimit = this.aiRateLimiter.check(aiKey);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
// Notify user they're rate limited
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `요청이 너무 많습니다. ${rateLimit.retryAfter}초 후 다시 시도해주세요.`,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.getChatContext(session.name);
|
||||
const response = await ai.generateResponse(content, context);
|
||||
|
||||
// Send AI response with slight delay
|
||||
await delay(800);
|
||||
const aiResponseTimestamp = Date.now();
|
||||
this.broadcast({
|
||||
type: 'message',
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: response,
|
||||
timestamp: aiResponseTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Save AI response to storage
|
||||
await this.saveMessage({
|
||||
id: BOT_ID,
|
||||
name: BOT_NAME,
|
||||
content: response,
|
||||
timestamp: aiResponseTimestamp,
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'rename') {
|
||||
// Validate new name
|
||||
const nameResult = UserNameSchema.safeParse(data.name);
|
||||
if (!nameResult.success) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: '유효하지 않은 이름입니다. (1-50자)',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
session.name = nameResult.data;
|
||||
|
||||
// Safely serialize updated session
|
||||
if (!this.setSession(ws, session)) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: '이름 변경 중 오류가 발생했습니다.',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify all users about the rename
|
||||
this.broadcast({
|
||||
type: 'userList',
|
||||
users: this.getUserList(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Failed to process message',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Hibernation API: Called when WebSocket is closed
|
||||
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||
const session = this.getSession(ws);
|
||||
if (session) {
|
||||
// Notify remaining users
|
||||
this.broadcast({
|
||||
type: 'leave',
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hibernation API: Called when WebSocket error occurs
|
||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||
console.error('WebSocket error:', error);
|
||||
}
|
||||
}
|
||||
328
worker/src/Context7Client.ts
Normal file
328
worker/src/Context7Client.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Context7 API Client
|
||||
* Documentation lookup service for technical questions
|
||||
*/
|
||||
|
||||
const CONTEXT7_API = 'https://api.context7.com/v1';
|
||||
const CONTEXT7_TIMEOUT_MS = 10000; // 10 seconds timeout
|
||||
|
||||
/**
|
||||
* Fetch with timeout using AbortController
|
||||
* Prevents hanging requests from blocking the chat AI loop
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number = CONTEXT7_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
interface LibraryMatch {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DocResult {
|
||||
content: string;
|
||||
source: string;
|
||||
relevance: number;
|
||||
}
|
||||
|
||||
export class Context7Client {
|
||||
private cache: Map<string, { data: unknown; expires: number }> = new Map();
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly MAX_CACHE_SIZE = 100; // Prevent unbounded cache growth
|
||||
private lastCleanup: number = 0;
|
||||
private readonly CLEANUP_INTERVAL = 60 * 1000; // Run cleanup at most once per minute
|
||||
|
||||
/**
|
||||
* Resolve a library name to Context7 library ID
|
||||
* FIXED: Now uses fetchWithTimeout to prevent hanging requests
|
||||
*/
|
||||
async resolveLibrary(libraryName: string, query: string): Promise<LibraryMatch | null> {
|
||||
const cacheKey = `lib:${libraryName}`;
|
||||
const cached = this.getFromCache<LibraryMatch>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${CONTEXT7_API}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryName, query }),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const result = await response.json() as LibraryMatch;
|
||||
this.setCache(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Handle timeout errors gracefully
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('Context7 resolve timeout');
|
||||
} else {
|
||||
console.error('Context7 resolve error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query documentation for a specific library
|
||||
* FIXED: Now uses fetchWithTimeout to prevent hanging requests
|
||||
*/
|
||||
async queryDocs(libraryId: string, query: string): Promise<DocResult[] | null> {
|
||||
const cacheKey = `docs:${libraryId}:${query}`;
|
||||
const cached = this.getFromCache<DocResult[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${CONTEXT7_API}/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, query }),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const result = await response.json() as DocResult[];
|
||||
this.setCache(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Handle timeout errors gracefully
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('Context7 query timeout');
|
||||
} else {
|
||||
console.error('Context7 query error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: Search documentation for a topic
|
||||
*/
|
||||
async searchDocs(topic: string): Promise<string | null> {
|
||||
// Extract library name from topic
|
||||
const libraryPatterns: Record<string, string> = {
|
||||
'react': 'react',
|
||||
'vue': 'vue',
|
||||
'next': 'next.js',
|
||||
'nuxt': 'nuxt',
|
||||
'node': 'node.js',
|
||||
'express': 'express',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'django': 'django',
|
||||
'fastapi': 'fastapi',
|
||||
'docker': 'docker',
|
||||
'kubernetes': 'kubernetes',
|
||||
'k8s': 'kubernetes',
|
||||
'postgres': 'postgresql',
|
||||
'mysql': 'mysql',
|
||||
'redis': 'redis',
|
||||
'mongodb': 'mongodb',
|
||||
'cloudflare': 'cloudflare workers',
|
||||
'incus': 'incus',
|
||||
'lxd': 'lxd',
|
||||
};
|
||||
|
||||
const lowerTopic = topic.toLowerCase();
|
||||
let libraryName = '';
|
||||
|
||||
for (const [pattern, lib] of Object.entries(libraryPatterns)) {
|
||||
if (lowerTopic.includes(pattern)) {
|
||||
libraryName = lib;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!libraryName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const library = await this.resolveLibrary(libraryName, topic);
|
||||
if (!library) return null;
|
||||
|
||||
const docs = await this.queryDocs(library.id, topic);
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
// Format the results
|
||||
return docs
|
||||
.slice(0, 3)
|
||||
.map(d => d.content)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
private getFromCache<T>(key: string): T | null {
|
||||
// Opportunistic cleanup on read
|
||||
this.cleanupIfNeeded();
|
||||
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
private setCache(key: string, data: unknown): void {
|
||||
// Cleanup before adding if cache is at max size
|
||||
if (this.cache.size >= this.MAX_CACHE_SIZE) {
|
||||
this.cleanupExpired();
|
||||
|
||||
// If still at max size after cleanup, remove oldest entries
|
||||
if (this.cache.size >= this.MAX_CACHE_SIZE) {
|
||||
this.evictOldest(Math.floor(this.MAX_CACHE_SIZE * 0.2)); // Remove 20% of entries
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
expires: Date.now() + this.CACHE_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired cache entries
|
||||
* Called opportunistically to prevent memory leaks
|
||||
*/
|
||||
private cleanupExpired(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.expires <= now) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
this.lastCleanup = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run cleanup only if enough time has passed since last cleanup
|
||||
*/
|
||||
private cleanupIfNeeded(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastCleanup >= this.CLEANUP_INTERVAL) {
|
||||
this.cleanupExpired();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest entries when cache is full
|
||||
*/
|
||||
private evictOldest(count: number): void {
|
||||
const entries = Array.from(this.cache.entries())
|
||||
.sort((a, b) => a[1].expires - b[1].expires);
|
||||
|
||||
for (let i = 0; i < Math.min(count, entries.length); i++) {
|
||||
this.cache.delete(entries[i][0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to force cache cleanup (for testing or manual trigger)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
this.lastCleanup = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Tool definitions for function calling
|
||||
export const context7Tools = [
|
||||
{
|
||||
name: 'searchDocumentation',
|
||||
description: '프로그래밍 라이브러리나 프레임워크의 공식 문서를 검색합니다. React, Vue, Node.js, Docker, Kubernetes 등의 기술 문서를 조회할 수 있습니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: '검색할 기술 주제 (예: "React hooks 사용법", "Docker compose 설정")',
|
||||
},
|
||||
},
|
||||
required: ['topic'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'getAnvilHostingInfo',
|
||||
description: 'Anvil Hosting 서비스 정보를 조회합니다. 가격, 스펙, 리전 정보 등을 제공합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['pricing', 'specs', 'regions', 'features'],
|
||||
description: '조회할 정보 카테고리',
|
||||
},
|
||||
},
|
||||
required: ['category'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Anvil Hosting information (could be fetched from API in production)
|
||||
export const anvilHostingInfo = {
|
||||
pricing: `**Anvil Hosting 요금제**
|
||||
- Starter: $5/월 - 1 vCPU, 1GB RAM, 20GB SSD
|
||||
- Pro: $15/월 - 2 vCPU, 4GB RAM, 80GB SSD
|
||||
- Business: $40/월 - 4 vCPU, 8GB RAM, 160GB SSD
|
||||
- Enterprise: 맞춤형 - 문의 필요
|
||||
|
||||
모든 요금제에 무제한 트래픽 포함!
|
||||
|
||||
👉 **바로 상담받기**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
specs: `**Anvil Hosting 서버 스펙**
|
||||
- CPU: AMD EPYC / Intel Xeon
|
||||
- 스토리지: NVMe SSD (초고속)
|
||||
- 네트워크: 10Gbps 업링크
|
||||
- 가상화: Incus/LXD (컨테이너 & VM)
|
||||
- OS: Ubuntu, Debian, Rocky, Alpine 등
|
||||
|
||||
👉 **자세한 문의**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
regions: `**Anvil Hosting 리전**
|
||||
- 🇰🇷 kr1 (한국) - 서울 데이터센터
|
||||
- 🇯🇵 jp1 (일본) - 도쿄 데이터센터
|
||||
|
||||
아시아 사용자에게 최적화된 저지연 네트워크!
|
||||
|
||||
👉 **리전 상담**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
features: `**Anvil Hosting 특징**
|
||||
- Incus/LXD 기반 컨테이너 & VM
|
||||
- 실시간 스냅샷 & 백업
|
||||
- 간편한 웹 콘솔
|
||||
- API & CLI 지원
|
||||
- 24/7 기술지원
|
||||
- DDoS 방어 기본 제공
|
||||
|
||||
👉 **무료 체험 신청**: https://t.me/AnvilForgeBot`,
|
||||
|
||||
contact: `**Anvil Hosting 문의하기**
|
||||
|
||||
📱 **텔레그램 봇**: https://t.me/AnvilForgeBot
|
||||
- 실시간 상담
|
||||
- 요금제 안내
|
||||
- 무료 체험 신청
|
||||
- 기술 지원 요청
|
||||
|
||||
🌐 **홈페이지**: https://anvil.it.com
|
||||
📧 **이메일**: support@anvil.it.com
|
||||
|
||||
지금 바로 텔레그램으로 문의하세요! 빠른 답변 드립니다.`,
|
||||
};
|
||||
134
worker/src/RateLimiter.ts
Normal file
134
worker/src/RateLimiter.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Simple sliding window rate limiter
|
||||
* Tracks requests per user/room and enforces limits
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
timestamps: number[];
|
||||
blocked: boolean;
|
||||
blockedUntil: number;
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
maxRequests: number; // Maximum requests allowed
|
||||
windowMs: number; // Time window in milliseconds
|
||||
blockDurationMs: number; // How long to block after limit exceeded
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: RateLimitConfig = {
|
||||
maxRequests: 10, // 10 requests
|
||||
windowMs: 60 * 1000, // per minute
|
||||
blockDurationMs: 5 * 60 * 1000, // 5 minute block
|
||||
};
|
||||
|
||||
export class RateLimiter {
|
||||
private entries: Map<string, RateLimitEntry> = new Map();
|
||||
private config: RateLimitConfig;
|
||||
private lastCleanup: number = Date.now();
|
||||
private readonly CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every 60 seconds
|
||||
|
||||
constructor(config: Partial<RateLimitConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed for the given key
|
||||
* @param key Unique identifier (e.g., "room:user" or "ip:address")
|
||||
* @returns Object with allowed status and remaining requests
|
||||
*/
|
||||
check(key: string): { allowed: boolean; remaining: number; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
|
||||
// Automatic cleanup: run every CLEANUP_INTERVAL_MS to prevent memory leak
|
||||
if (now - this.lastCleanup > this.CLEANUP_INTERVAL_MS) {
|
||||
this.cleanup();
|
||||
this.lastCleanup = now;
|
||||
}
|
||||
|
||||
let entry = this.entries.get(key);
|
||||
|
||||
// Create new entry if doesn't exist
|
||||
if (!entry) {
|
||||
entry = { timestamps: [], blocked: false, blockedUntil: 0 };
|
||||
this.entries.set(key, entry);
|
||||
}
|
||||
|
||||
// Check if currently blocked
|
||||
if (entry.blocked) {
|
||||
if (now < entry.blockedUntil) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.ceil((entry.blockedUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
// Block expired, reset
|
||||
entry.blocked = false;
|
||||
entry.timestamps = [];
|
||||
}
|
||||
|
||||
// Clean old timestamps outside the window
|
||||
const windowStart = now - this.config.windowMs;
|
||||
entry.timestamps = entry.timestamps.filter(ts => ts > windowStart);
|
||||
|
||||
// Check if limit exceeded
|
||||
if (entry.timestamps.length >= this.config.maxRequests) {
|
||||
entry.blocked = true;
|
||||
entry.blockedUntil = now + this.config.blockDurationMs;
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.ceil(this.config.blockDurationMs / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Record this request
|
||||
entry.timestamps.push(now);
|
||||
const remaining = this.config.maxRequests - entry.timestamps.length;
|
||||
|
||||
return { allowed: true, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries (call periodically)
|
||||
* Fixed: More aggressive cleanup to prevent memory accumulation
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const windowStart = now - this.config.windowMs;
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
// Filter out stale timestamps first
|
||||
const activeTimestamps = entry.timestamps.filter(ts => ts >= windowStart);
|
||||
|
||||
// Case 1: Block has expired - always delete regardless of timestamps
|
||||
// Once unblocked, the entry will be recreated fresh if user returns
|
||||
if (entry.blocked && now > entry.blockedUntil) {
|
||||
this.entries.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: Not blocked and no recent activity - safe to delete
|
||||
if (!entry.blocked && activeTimestamps.length === 0) {
|
||||
this.entries.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: Entry is still active - update timestamps to remove stale ones
|
||||
// This prevents timestamp array from growing indefinitely
|
||||
if (activeTimestamps.length !== entry.timestamps.length) {
|
||||
entry.timestamps = activeTimestamps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instances should be created within the Durable Object or Worker class
|
||||
// to ensure proper lifecycle management and state isolation.
|
||||
98
worker/src/config.ts
Normal file
98
worker/src/config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Centralized configuration for the chat worker
|
||||
* All magic numbers and hardcoded values should be defined here
|
||||
*/
|
||||
|
||||
// Bot Configuration
|
||||
export const BOT_CONFIG = {
|
||||
name: '방장',
|
||||
id: 'ai-manager-bot',
|
||||
} as const;
|
||||
|
||||
// Storage Configuration
|
||||
export const STORAGE_CONFIG = {
|
||||
messagesKey: 'chat_messages',
|
||||
maxStoredMessages: 50,
|
||||
maxRecentMessages: 20,
|
||||
historyToSend: 20,
|
||||
aiContextMessages: 10,
|
||||
} as const;
|
||||
|
||||
// API Configuration
|
||||
export const API_CONFIG = {
|
||||
openaiTimeoutMs: 15000,
|
||||
weatherTimeoutMs: 5000,
|
||||
openaiModel: 'gpt-4o-mini',
|
||||
maxTokens: 500,
|
||||
temperature: 0.8,
|
||||
} as const;
|
||||
|
||||
// Delay Configuration (in milliseconds)
|
||||
export const DELAY_CONFIG = {
|
||||
botWelcome: 500,
|
||||
moderationWarning: 300,
|
||||
aiResponse: 800,
|
||||
} as const;
|
||||
|
||||
// AI Response Configuration
|
||||
export const AI_RESPONSE_CONFIG = {
|
||||
// Probability of AI responding to messages that don't explicitly mention it
|
||||
randomResponseProbability: 0.15,
|
||||
} as const;
|
||||
|
||||
// CORS Configuration
|
||||
export const CORS_CONFIG = {
|
||||
allowedOrigins: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3000',
|
||||
'https://chat.anvil.it.com',
|
||||
'https://anvil-chat.pages.dev',
|
||||
],
|
||||
allowedMethods: 'GET, POST, OPTIONS',
|
||||
allowedHeaders: 'Content-Type',
|
||||
} as const;
|
||||
|
||||
// City Coordinates for Weather API
|
||||
export const CITY_COORDINATES: Record<string, { lat: number; lon: number; name: string }> = {
|
||||
'seoul': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'서울': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
'busan': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'부산': { lat: 35.1796, lon: 129.0756, name: '부산' },
|
||||
'tokyo': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'도쿄': { lat: 35.6762, lon: 139.6503, name: '도쿄' },
|
||||
'osaka': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'오사카': { lat: 34.6937, lon: 135.5023, name: '오사카' },
|
||||
'newyork': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'뉴욕': { lat: 40.7128, lon: -74.0060, name: '뉴욕' },
|
||||
'london': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'런던': { lat: 51.5074, lon: -0.1278, name: '런던' },
|
||||
'default': { lat: 37.5665, lon: 126.9780, name: '서울' },
|
||||
};
|
||||
|
||||
// Weather Code Translations
|
||||
export const WEATHER_CODES: Record<number, string> = {
|
||||
0: '맑음 ☀️',
|
||||
1: '대체로 맑음 🌤️',
|
||||
2: '부분적 흐림 ⛅',
|
||||
3: '흐림 ☁️',
|
||||
45: '안개 🌫️',
|
||||
48: '짙은 안개 🌫️',
|
||||
51: '가벼운 이슬비 🌧️',
|
||||
53: '이슬비 🌧️',
|
||||
55: '강한 이슬비 🌧️',
|
||||
61: '가벼운 비 🌧️',
|
||||
63: '비 🌧️',
|
||||
65: '강한 비 🌧️',
|
||||
71: '가벼운 눈 🌨️',
|
||||
73: '눈 🌨️',
|
||||
75: '폭설 ❄️',
|
||||
77: '싸락눈 🌨️',
|
||||
80: '소나기 🌧️',
|
||||
81: '강한 소나기 🌧️',
|
||||
82: '폭우 ⛈️',
|
||||
85: '눈소나기 🌨️',
|
||||
86: '강한 눈소나기 🌨️',
|
||||
95: '뇌우 ⛈️',
|
||||
96: '우박 동반 뇌우 ⛈️',
|
||||
99: '강한 우박 동반 뇌우 ⛈️',
|
||||
};
|
||||
137
worker/src/index.ts
Normal file
137
worker/src/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ChatRoom } from './ChatRoom';
|
||||
|
||||
export { ChatRoom };
|
||||
|
||||
export interface Env {
|
||||
CHAT_ROOM: DurableObjectNamespace;
|
||||
OPENAI_API_KEY: string;
|
||||
ENVIRONMENT: string;
|
||||
}
|
||||
|
||||
// Allowed origins for CORS
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://chat.anvil.it.com',
|
||||
'https://chat-frontend-4wf.pages.dev',
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3000',
|
||||
];
|
||||
|
||||
function getCorsHeaders(origin: string | null): Record<string, string> {
|
||||
// Only allow exact matches or specific Pages project subdomains
|
||||
const isAllowed = origin && ALLOWED_ORIGINS.some(allowed => {
|
||||
if (origin === allowed) return true;
|
||||
// Only allow subdomains of our specific Pages project
|
||||
if (allowed.includes('.pages.dev')) {
|
||||
const projectDomain = allowed.replace('https://', '');
|
||||
// Match: xxx.chat-frontend-4wf.pages.dev (preview deployments)
|
||||
return origin.endsWith(projectDomain) ||
|
||||
origin.match(/^https:\/\/[a-f0-9]+\.chat-frontend-4wf\.pages\.dev$/);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const allowedOrigin = isAllowed ? origin : ALLOWED_ORIGINS[0];
|
||||
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
// Security headers
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': "default-src 'self'; connect-src 'self' wss: https:; style-src 'self' 'unsafe-inline'; script-src 'self'",
|
||||
};
|
||||
}
|
||||
|
||||
function handleCors(request: Request): Response | null {
|
||||
if (request.method === 'OPTIONS') {
|
||||
const origin = request.headers.get('Origin');
|
||||
return new Response(null, { headers: getCorsHeaders(origin) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addCorsHeaders(response: Response, request: Request): Response {
|
||||
const origin = request.headers.get('Origin');
|
||||
const newHeaders = new Headers(response.headers);
|
||||
Object.entries(getCorsHeaders(origin)).forEach(([key, value]) => {
|
||||
newHeaders.set(key, value);
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Handle CORS preflight
|
||||
const corsResponse = handleCors(request);
|
||||
if (corsResponse) return corsResponse;
|
||||
|
||||
try {
|
||||
// Route: GET /api/rooms/:roomId/websocket
|
||||
const wsMatch = path.match(/^\/api\/rooms\/([^/]+)\/websocket$/);
|
||||
if (wsMatch) {
|
||||
const roomId = wsMatch[1];
|
||||
return await handleWebSocketUpgrade(request, env, roomId);
|
||||
}
|
||||
|
||||
// Route: GET /api/rooms/:roomId/users
|
||||
const usersMatch = path.match(/^\/api\/rooms\/([^/]+)\/users$/);
|
||||
if (usersMatch) {
|
||||
const roomId = usersMatch[1];
|
||||
return await handleGetUsers(request, env, roomId);
|
||||
}
|
||||
|
||||
// Route: GET /api/health
|
||||
if (path === '/api/health') {
|
||||
return addCorsHeaders(Response.json({ status: 'ok', timestamp: Date.now() }), request);
|
||||
}
|
||||
|
||||
// Route: GET / - Simple landing page
|
||||
if (path === '/') {
|
||||
return new Response('Chat API Server. Use /api/rooms/:roomId/websocket to connect.', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
return addCorsHeaders(new Response('Not Found', { status: 404 }), request);
|
||||
} catch (error) {
|
||||
console.error('Worker error:', error);
|
||||
return addCorsHeaders(Response.json({ error: 'Internal Server Error' }, { status: 500 }), request);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function handleWebSocketUpgrade(request: Request, env: Env, roomId: string): Promise<Response> {
|
||||
// Verify WebSocket upgrade request
|
||||
if (request.headers.get('Upgrade') !== 'websocket') {
|
||||
return new Response('Expected WebSocket upgrade', { status: 426 });
|
||||
}
|
||||
|
||||
// Get or create the Durable Object for this room
|
||||
const id = env.CHAT_ROOM.idFromName(roomId);
|
||||
const stub = env.CHAT_ROOM.get(id);
|
||||
|
||||
// Forward the request to the Durable Object
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
async function handleGetUsers(request: Request, env: Env, roomId: string): Promise<Response> {
|
||||
const id = env.CHAT_ROOM.idFromName(roomId);
|
||||
const stub = env.CHAT_ROOM.get(id);
|
||||
|
||||
const usersUrl = new URL(request.url);
|
||||
usersUrl.pathname = '/users';
|
||||
|
||||
const response = await stub.fetch(new Request(usersUrl.toString()));
|
||||
return addCorsHeaders(response, request);
|
||||
}
|
||||
84
worker/src/logger.ts
Normal file
84
worker/src/logger.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Environment-aware logging utility
|
||||
* Reduces noise in production while preserving debug info in development
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LoggerConfig {
|
||||
level: LogLevel;
|
||||
isDevelopment: boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
class Logger {
|
||||
private config: LoggerConfig;
|
||||
|
||||
constructor(isDevelopment: boolean = false) {
|
||||
this.config = {
|
||||
level: isDevelopment ? 'debug' : 'warn',
|
||||
isDevelopment,
|
||||
};
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.level];
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, message: string, data?: unknown): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
if (data !== undefined) {
|
||||
return `${prefix} ${message} ${JSON.stringify(data)}`;
|
||||
}
|
||||
return `${prefix} ${message}`;
|
||||
}
|
||||
|
||||
debug(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.log(this.formatMessage('debug', message, data));
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('info')) {
|
||||
console.log(this.formatMessage('info', message, data));
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
console.warn(this.formatMessage('warn', message, data));
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, data?: unknown): void {
|
||||
if (this.shouldLog('error')) {
|
||||
console.error(this.formatMessage('error', message, data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance - will be configured based on environment
|
||||
let loggerInstance: Logger | null = null;
|
||||
|
||||
export function getLogger(isDevelopment?: boolean): Logger {
|
||||
if (!loggerInstance) {
|
||||
// Default to production mode if not specified
|
||||
loggerInstance = new Logger(isDevelopment ?? false);
|
||||
}
|
||||
return loggerInstance;
|
||||
}
|
||||
|
||||
export function initLogger(isDevelopment: boolean): Logger {
|
||||
loggerInstance = new Logger(isDevelopment);
|
||||
return loggerInstance;
|
||||
}
|
||||
|
||||
export { Logger };
|
||||
34
worker/src/validation.ts
Normal file
34
worker/src/validation.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// WebSocket incoming message schemas
|
||||
export const MessageSchema = z.object({
|
||||
type: z.literal('message'),
|
||||
content: z.string().min(1).max(2000).trim(),
|
||||
});
|
||||
|
||||
export const RenameSchema = z.object({
|
||||
type: z.literal('rename'),
|
||||
name: z.string().min(1).max(50).trim(),
|
||||
});
|
||||
|
||||
export const WebSocketMessageSchema = z.discriminatedUnion('type', [
|
||||
MessageSchema,
|
||||
RenameSchema,
|
||||
]);
|
||||
|
||||
// Room ID validation
|
||||
export const RoomIdSchema = z.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Room ID must be alphanumeric with dashes and underscores only');
|
||||
|
||||
// User name validation
|
||||
export const UserNameSchema = z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.trim();
|
||||
|
||||
// Types
|
||||
export type WebSocketMessage = z.infer<typeof WebSocketMessageSchema>;
|
||||
export type MessageData = z.infer<typeof MessageSchema>;
|
||||
export type RenameData = z.infer<typeof RenameSchema>;
|
||||
15
worker/tsconfig.json
Normal file
15
worker/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
18
worker/wrangler.toml
Normal file
18
worker/wrangler.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
name = "chat-worker"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
|
||||
]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["ChatRoom"]
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "development"
|
||||
|
||||
# OpenAI API key is stored as a secret
|
||||
# Run: wrangler secret put OPENAI_API_KEY
|
||||
Reference in New Issue
Block a user