diff --git a/docs/plans/2026-02-05-conversation-storage-design.md b/docs/plans/2026-02-05-conversation-storage-design.md new file mode 100644 index 0000000..bf5e677 --- /dev/null +++ b/docs/plans/2026-02-05-conversation-storage-design.md @@ -0,0 +1,180 @@ +# 대화 저장 시스템 리팩토링 설계 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 기존 요약 기반 대화 저장을 전체 대화 기록 보존 시스템으로 전환 + +**Architecture:** 사용자별 동적 테이블 생성 + 메타 테이블 관리 + 6개월 후 아카이브 + +**Tech Stack:** Cloudflare Workers, D1 SQLite, OpenAI GPT-4o-mini + +--- + +## 1. 데이터베이스 구조 + +### 메타 테이블 (`conversation_tables`) +```sql +CREATE TABLE conversation_tables ( + telegram_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, -- conv_123456789 + message_count INTEGER DEFAULT 0, + last_message_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_conv_tables_last_msg ON conversation_tables(last_message_at DESC); +``` + +### 동적 사용자 테이블 (`conv_{telegram_id}`) +```sql +CREATE TABLE conv_{telegram_id} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), + content TEXT NOT NULL, + tool_calls TEXT, -- JSON: [{name, arguments, id}] + tool_results TEXT, -- JSON: [{tool_call_id, result}] + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_conv_{telegram_id}_created ON conv_{telegram_id}(created_at DESC); +``` + +### 기존 테이블 변경 +- `message_buffer`: 마이그레이션 후 삭제 +- `summaries`: 유지 (아카이브 요약 저장용) + +--- + +## 2. 서비스 구조 + +### ConversationService (`src/services/conversation-service.ts`) + +```typescript +interface ConversationMessage { + id?: number; + role: 'user' | 'assistant'; + content: string; + tool_calls?: string; // JSON + tool_results?: string; // JSON + created_at?: string; +} + +// 테이블 생성/확인 +export async function ensureConversationTable(db: D1Database, telegramId: string): Promise + +// 메시지 저장 +export async function saveMessage(db: D1Database, telegramId: string, message: ConversationMessage): Promise + +// 최근 메시지 조회 +export async function getRecentMessages(db: D1Database, telegramId: string, limit: number): Promise + +// 키워드 검색 +export async function searchRelevantMessages(db: D1Database, telegramId: string, keywords: string[], limit: number): Promise + +// 스마트 컨텍스트 (최근 20개 + 관련 10개) +export async function getSmartContext(db: D1Database, telegramId: string, currentMessage: string): Promise + +// 통계 조회 +export async function getConversationStats(db: D1Database, telegramId: string): Promise +``` + +### ArchiveService (`src/services/archive-service.ts`) + +```typescript +// 전체 아카이브 (Cron) +export async function archiveOldConversations(env: Env): Promise + +// 사용자별 아카이브 +export async function archiveUserConversations(env: Env, telegramId: string, olderThanDays: number): Promise + +// 아카이브 요약 생성 +export async function generateArchiveSummary(env: Env, messages: ConversationMessage[]): Promise +``` + +--- + +## 3. 아카이브 정책 + +- **트리거**: 6개월(180일) 이상 된 대화 +- **주기**: 매일 UTC 15:00 (KST 00:00) +- **단위**: 100개 메시지씩 요약 +- **저장**: summaries 테이블에 기간 정보 포함 + +### 아카이브 프로세스 +``` +1. conversation_tables 순회 +2. 각 conv_{id}에서 180일 이상 된 메시지 조회 +3. 100개 단위로 AI 요약 생성 +4. summaries에 저장: "[2024-01-01 ~ 2024-06-30 아카이브] 요약내용..." +5. 원본 메시지 삭제 +6. message_count 업데이트 +``` + +--- + +## 4. AI 컨텍스트 구성 + +### 스마트 컨텍스트 알고리즘 +``` +1. 최근 20개 메시지 가져오기 +2. 현재 메시지에서 키워드 추출 (명사, 2글자 이상, 최대 5개) +3. 키워드로 과거 대화 검색 (LIKE %keyword%) +4. 관련 대화 최대 10개 추가 +5. 중복 제거 + 시간순 정렬 +6. 아카이브 요약도 컨텍스트에 포함 +``` + +### 키워드 추출 +- 불용어 제외: 은, 는, 이, 가, 을, 를, 에, 도, 로, 의, 와, 과 +- 최소 2글자 이상 +- 최대 5개 키워드 + +--- + +## 5. 사용자 명령어 + +| 명령어 | 설명 | +|--------|------| +| `/history` | 최근 20개 대화 조회 | +| `/history N` | 최근 N개 대화 조회 | +| `/search 키워드` | 키워드로 대화 검색 | +| `/stats` | 대화 통계 (총 메시지, 첫 대화일 등) | + +--- + +## 6. 마이그레이션 계획 + +### Step 1: 스키마 생성 +- `conversation_tables` 메타 테이블 생성 + +### Step 2: 데이터 이동 +- 기존 `message_buffer` 데이터 → 각 사용자 `conv_{id}` 테이블로 이동 +- 기존 사용자 목록 기반으로 테이블 생성 + +### Step 3: 코드 배포 +- conversation-service.ts 추가 +- archive-service.ts 추가 +- summary-service.ts 수정 +- index.ts 수정 +- commands.ts 수정 + +### Step 4: 정리 +- message_buffer 테이블 삭제 (검증 후) + +--- + +## 7. 파일 변경 목록 + +### 신규 파일 +- `src/services/conversation-service.ts` +- `src/services/archive-service.ts` +- `migrations/008_conversation_tables.sql` + +### 수정 파일 +- `src/summary-service.ts` - getSmartContext 사용으로 변경 +- `src/index.ts` - saveMessage 호출로 변경 +- `src/commands.ts` - /history, /search, /stats 추가 +- `src/types.ts` - ConversationMessage 타입 추가 + +### 삭제 (마이그레이션 후) +- `message_buffer` 테이블 관련 코드 diff --git a/docs/plans/2026-02-05-conversation-storage-implementation.md b/docs/plans/2026-02-05-conversation-storage-implementation.md new file mode 100644 index 0000000..7a44552 --- /dev/null +++ b/docs/plans/2026-02-05-conversation-storage-implementation.md @@ -0,0 +1,1174 @@ +# 대화 저장 시스템 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 요약 기반 대화 저장을 전체 대화 기록 시스템으로 전환 + +**Architecture:** 메타 테이블(conversation_tables) + 사용자별 동적 테이블(conv_{telegram_id}) + 6개월 아카이브 + +**Tech Stack:** Cloudflare Workers, D1 SQLite, TypeScript, OpenAI GPT-4o-mini + +--- + +## Task 1: 타입 정의 추가 + +**Files:** +- Modify: `src/types.ts` + +**Step 1: ConversationMessage 타입 추가** + +`src/types.ts` 파일 끝에 추가: + +```typescript +// ============================================ +// Conversation Storage Types +// ============================================ + +export interface ConversationMessage { + id?: number; + role: 'user' | 'assistant'; + content: string; + tool_calls?: string; // JSON: [{name, arguments, id}] + tool_results?: string; // JSON: [{tool_call_id, result}] + created_at?: string; +} + +export interface ConversationTableMeta { + telegram_id: string; + table_name: string; + message_count: number; + last_message_at: string | null; + created_at: string; +} + +export interface ConversationStats { + telegram_id: string; + message_count: number; + first_message_at: string | null; + last_message_at: string | null; + archived_summaries: number; +} + +export interface ArchiveResult { + processed_users: number; + archived_messages: number; + created_summaries: number; + errors: string[]; +} +``` + +**Step 2: 테스트 (타입 체크)** + +Run: `npm run typecheck` +Expected: PASS (no type errors) + +**Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "feat: add conversation storage types" +``` + +--- + +## Task 2: 마이그레이션 SQL 작성 + +**Files:** +- Create: `migrations/008_conversation_tables.sql` + +**Step 1: 마이그레이션 파일 생성** + +```sql +-- 대화 테이블 메타 정보 +CREATE TABLE IF NOT EXISTS conversation_tables ( + telegram_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + message_count INTEGER DEFAULT 0, + last_message_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS idx_conv_tables_last_msg ON conversation_tables(last_message_at DESC); +CREATE INDEX IF NOT EXISTS idx_conv_tables_count ON conversation_tables(message_count DESC); +``` + +**Step 2: 로컬 테스트** + +Run: `wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql` +Expected: 성공 메시지 + +**Step 3: Commit** + +```bash +git add migrations/008_conversation_tables.sql +git commit -m "feat: add conversation_tables migration" +``` + +--- + +## Task 3: 대화 저장 서비스 구현 + +**Files:** +- Create: `src/services/conversation-storage.ts` + +**Step 1: 기본 구조 작성** + +```typescript +import type { Env, ConversationMessage, ConversationTableMeta, ConversationStats } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('conversation-storage'); + +// 불용어 목록 (키워드 추출 시 제외) +const STOP_WORDS = new Set([ + '은', '는', '이', '가', '을', '를', '에', '도', '로', '의', '와', '과', + '에서', '으로', '하고', '해서', '했어', '할게', '할까', '해줘', '해주세요', + '좀', '그', '저', '이거', '저거', '그거', '뭐', '어떻게', '왜', '언제', + '네', '예', '아니', '응', '음', '아', '오', 'ㅇㅇ', 'ㄴㄴ' +]); + +/** + * 사용자별 대화 테이블 존재 확인 및 생성 + */ +export async function ensureConversationTable( + db: D1Database, + telegramId: string +): Promise { + const tableName = `conv_${telegramId}`; + + // 1. 메타 테이블에서 확인 + const existing = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (existing) { + return existing.table_name; + } + + // 2. 테이블 생성 (raw SQL - DDL은 prepared statement 불가) + await db.exec(` + CREATE TABLE IF NOT EXISTS ${tableName} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), + content TEXT NOT NULL, + tool_calls TEXT, + tool_results TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // 3. 인덱스 생성 + await db.exec(` + CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC) + `); + + // 4. 메타 테이블에 등록 + await db + .prepare('INSERT INTO conversation_tables (telegram_id, table_name) VALUES (?, ?)') + .bind(telegramId, tableName) + .run(); + + logger.info('대화 테이블 생성', { telegramId, tableName }); + + return tableName; +} + +/** + * 메시지 저장 + */ +export async function saveConversationMessage( + db: D1Database, + telegramId: string, + message: Omit +): Promise { + const tableName = await ensureConversationTable(db, telegramId); + + // 1. 메시지 저장 + await db + .prepare(`INSERT INTO ${tableName} (role, content, tool_calls, tool_results) VALUES (?, ?, ?, ?)`) + .bind( + message.role, + message.content, + message.tool_calls || null, + message.tool_results || null + ) + .run(); + + // 2. 메타 테이블 업데이트 + await db + .prepare(` + UPDATE conversation_tables + SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP + WHERE telegram_id = ? + `) + .bind(telegramId) + .run(); +} + +/** + * 최근 메시지 조회 + */ +export async function getRecentConversations( + db: D1Database, + telegramId: string, + limit: number = 20 +): Promise { + // 테이블 존재 확인 + const meta = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (!meta) { + return []; + } + + const { results } = await db + .prepare(` + SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${meta.table_name} + ORDER BY created_at DESC + LIMIT ? + `) + .bind(limit) + .all(); + + // 시간순 정렬 (오래된 것 → 최신) + return (results || []).reverse(); +} + +/** + * 키워드로 관련 메시지 검색 + */ +export async function searchConversations( + db: D1Database, + telegramId: string, + keywords: string[], + limit: number = 10 +): Promise { + const meta = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (!meta || keywords.length === 0) { + return []; + } + + // LIKE 쿼리 생성 (OR 조건) + const likeConditions = keywords.map(() => `content LIKE ?`).join(' OR '); + const likeValues = keywords.map(k => `%${k}%`); + + const { results } = await db + .prepare(` + SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${meta.table_name} + WHERE ${likeConditions} + ORDER BY created_at DESC + LIMIT ? + `) + .bind(...likeValues, limit) + .all(); + + return results || []; +} + +/** + * 메시지에서 키워드 추출 + */ +export function extractKeywords(message: string): string[] { + // 1. 특수문자 제거, 공백으로 분리 + const words = message + .replace(/[^\w\s가-힣]/g, ' ') + .split(/\s+/) + .filter(w => w.length >= 2); + + // 2. 불용어 제거 + const filtered = words.filter(w => !STOP_WORDS.has(w)); + + // 3. 중복 제거 후 최대 5개 + const unique = [...new Set(filtered)]; + + return unique.slice(0, 5); +} + +/** + * 스마트 컨텍스트 조회 (최근 20개 + 관련 10개) + */ +export async function getSmartContext( + db: D1Database, + telegramId: string, + currentMessage: string +): Promise { + // 1. 최근 20개 조회 + const recent = await getRecentConversations(db, telegramId, 20); + + // 2. 키워드 추출 + const keywords = extractKeywords(currentMessage); + + if (keywords.length === 0) { + return recent; + } + + // 3. 관련 대화 검색 (최근 20개 제외) + const recentIds = new Set(recent.map(m => m.id)); + const related = await searchConversations(db, telegramId, keywords, 20); + const filteredRelated = related + .filter(m => !recentIds.has(m.id)) + .slice(0, 10); + + // 4. 병합 및 시간순 정렬 + const combined = [...filteredRelated, ...recent]; + combined.sort((a, b) => { + const timeA = new Date(a.created_at || 0).getTime(); + const timeB = new Date(b.created_at || 0).getTime(); + return timeA - timeB; + }); + + return combined; +} + +/** + * 대화 통계 조회 + */ +export async function getConversationStats( + db: D1Database, + telegramId: string +): Promise { + const meta = await db + .prepare('SELECT * FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first(); + + if (!meta) { + return null; + } + + // 첫 메시지 시간 조회 + const firstMsg = await db + .prepare(`SELECT MIN(created_at) as first_at FROM ${meta.table_name}`) + .first<{ first_at: string | null }>(); + + // 아카이브된 요약 수 + const archiveCount = await db + .prepare('SELECT COUNT(*) as cnt FROM summaries WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)') + .bind(telegramId) + .first<{ cnt: number }>(); + + return { + telegram_id: telegramId, + message_count: meta.message_count, + first_message_at: firstMsg?.first_at || null, + last_message_at: meta.last_message_at, + archived_summaries: archiveCount?.cnt || 0, + }; +} + +/** + * 대화 히스토리 조회 (명령어용) + */ +export async function getConversationHistory( + db: D1Database, + telegramId: string, + limit: number = 20, + offset: number = 0 +): Promise { + const meta = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (!meta) { + return []; + } + + const { results } = await db + .prepare(` + SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${meta.table_name} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `) + .bind(limit, offset) + .all(); + + return (results || []).reverse(); +} +``` + +**Step 2: 타입 체크** + +Run: `npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/services/conversation-storage.ts +git commit -m "feat: implement conversation storage service" +``` + +--- + +## Task 4: 아카이브 서비스 구현 + +**Files:** +- Create: `src/services/archive-service.ts` + +**Step 1: 아카이브 서비스 작성** + +```typescript +import type { Env, ConversationMessage, ArchiveResult } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('archive-service'); + +const ARCHIVE_DAYS = 180; // 6개월 +const BATCH_SIZE = 100; // 요약당 메시지 수 + +/** + * 메시지 배열을 AI 요약으로 변환 + */ +async function generateArchiveSummary( + env: Env, + messages: ConversationMessage[], + startDate: string, + endDate: string +): Promise { + // 사용자 메시지만 추출 + const userMessages = messages + .filter(m => m.role === 'user') + .map(m => `- ${m.content}`) + .join('\n'); + + const prompt = `당신은 대화 아카이브 전문가입니다. +아래 기간의 사용자 발언을 분석하여 핵심 정보를 요약하세요. + +## 기간 +${startDate} ~ ${endDate} + +## 사용자 발언 (${messages.filter(m => m.role === 'user').length}개) +${userMessages} + +## 요구사항 +1. 주요 관심사, 요청사항, 질문 주제를 파악 +2. 중요한 맥락 정보 (직업, 프로젝트, 목표 등) 보존 +3. 무의미한 내용 (인사, 단순 확인) 제외 +4. 300-400자 이내로 작성 +5. 한국어로 작성 + +아카이브 요약:`; + + if (env.OPENAI_API_KEY) { + const { generateProfileWithOpenAI } = await import('../openai-service'); + const summary = await generateProfileWithOpenAI(env, prompt); + return `[${startDate} ~ ${endDate} 아카이브] ${summary}`; + } + + // 폴백: 단순 요약 + return `[${startDate} ~ ${endDate} 아카이브] 이 기간 동안 ${messages.length}개의 대화가 있었습니다.`; +} + +/** + * 특정 사용자의 오래된 대화 아카이브 + */ +export async function archiveUserConversations( + env: Env, + telegramId: string, + tableName: string, + olderThanDays: number = ARCHIVE_DAYS +): Promise<{ archived: number; summaries: number }> { + const db = env.DB; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + const cutoffStr = cutoffDate.toISOString(); + + // 1. 아카이브 대상 메시지 조회 + const { results: oldMessages } = await db + .prepare(` + SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${tableName} + WHERE created_at < ? + ORDER BY created_at ASC + `) + .bind(cutoffStr) + .all(); + + if (!oldMessages || oldMessages.length === 0) { + return { archived: 0, summaries: 0 }; + } + + logger.info('아카이브 대상', { telegramId, count: oldMessages.length }); + + // 2. 사용자 ID 조회 + const user = await db + .prepare('SELECT id FROM users WHERE telegram_id = ?') + .bind(telegramId) + .first<{ id: number }>(); + + if (!user) { + logger.warn('사용자 없음', { telegramId }); + return { archived: 0, summaries: 0 }; + } + + // 3. BATCH_SIZE 단위로 요약 생성 + let summariesCreated = 0; + const messageIds: number[] = []; + + for (let i = 0; i < oldMessages.length; i += BATCH_SIZE) { + const batch = oldMessages.slice(i, i + BATCH_SIZE); + const startDate = batch[0].created_at?.split('T')[0] || 'unknown'; + const endDate = batch[batch.length - 1].created_at?.split('T')[0] || 'unknown'; + + // 요약 생성 + const summary = await generateArchiveSummary(env, batch, startDate, endDate); + + // 최신 generation 조회 + const latestGen = await db + .prepare('SELECT MAX(generation) as gen FROM summaries WHERE user_id = ?') + .bind(user.id) + .first<{ gen: number | null }>(); + + const newGeneration = (latestGen?.gen || 0) + 1; + + // summaries 테이블에 저장 + await db + .prepare(` + INSERT INTO summaries (user_id, chat_id, generation, summary, message_count) + VALUES (?, ?, ?, ?, ?) + `) + .bind(user.id, telegramId, newGeneration, summary, batch.length) + .run(); + + summariesCreated++; + batch.forEach(m => m.id && messageIds.push(m.id)); + } + + // 4. 원본 메시지 삭제 + if (messageIds.length > 0) { + // SQLite는 IN 절에 많은 파라미터 바인딩이 어려우므로 청크 단위 삭제 + const chunkSize = 100; + for (let i = 0; i < messageIds.length; i += chunkSize) { + const chunk = messageIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db + .prepare(`DELETE FROM ${tableName} WHERE id IN (${placeholders})`) + .bind(...chunk) + .run(); + } + } + + // 5. 메타 테이블 업데이트 + await db + .prepare('UPDATE conversation_tables SET message_count = message_count - ? WHERE telegram_id = ?') + .bind(messageIds.length, telegramId) + .run(); + + logger.info('아카이브 완료', { + telegramId, + archived: messageIds.length, + summaries: summariesCreated + }); + + return { archived: messageIds.length, summaries: summariesCreated }; +} + +/** + * 전체 사용자 아카이브 (Cron용) + */ +export async function archiveOldConversations(env: Env): Promise { + const db = env.DB; + const result: ArchiveResult = { + processed_users: 0, + archived_messages: 0, + created_summaries: 0, + errors: [], + }; + + // 1. 모든 대화 테이블 조회 + const { results: tables } = await db + .prepare('SELECT telegram_id, table_name FROM conversation_tables') + .all<{ telegram_id: string; table_name: string }>(); + + if (!tables || tables.length === 0) { + logger.info('아카이브 대상 없음'); + return result; + } + + // 2. 각 사용자별 아카이브 실행 + for (const table of tables) { + try { + const { archived, summaries } = await archiveUserConversations( + env, + table.telegram_id, + table.table_name + ); + + if (archived > 0) { + result.processed_users++; + result.archived_messages += archived; + result.created_summaries += summaries; + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + result.errors.push(`${table.telegram_id}: ${errMsg}`); + logger.error('사용자 아카이브 실패', error as Error, { + telegramId: table.telegram_id + }); + } + } + + logger.info('전체 아카이브 완료', result); + return result; +} +``` + +**Step 2: 타입 체크** + +Run: `npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/services/archive-service.ts +git commit -m "feat: implement archive service for old conversations" +``` + +--- + +## Task 5: summary-service.ts 수정 + +**Files:** +- Modify: `src/summary-service.ts` + +**Step 1: getSmartContext 연동** + +`generateAIResponse` 함수 수정 - `getConversationContext` 대신 `getSmartContext` 사용: + +```typescript +// 기존 import 유지 + 추가 +import { getSmartContext } from './services/conversation-storage'; + +// generateAIResponse 함수 내부 수정 +export async function generateAIResponse( + env: Env, + userId: number, + chatId: string, + userMessage: string, + telegramUserId?: string +): Promise { + // 기존 context 대신 스마트 컨텍스트 사용 + const smartMessages = telegramUserId + ? await getSmartContext(env.DB, telegramUserId, userMessage) + : []; + + // 아카이브된 요약도 조회 (기존 로직 유지) + const summaries = await getAllSummaries(env.DB, userId, chatId); + + // 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시) + const integratedProfile = summaries.length > 0 + ? summaries + .slice() + .reverse() + .map((s) => `[v${s.generation}] ${s.summary}`) + .join('\n\n') + : null; + + // ... 기존 systemPrompt 생성 로직 유지 ... + + // recentContext를 스마트 컨텍스트로 교체 + const recentContext = smartMessages.slice(-30).map((m) => ({ + role: m.role === 'user' ? 'user' as const : 'assistant' as const, + content: m.content, + })); + + // ... 나머지 로직 유지 ... +} +``` + +**Step 2: addToBuffer 함수는 유지** (마이그레이션 완료 전까지 호환성) + +기존 `addToBuffer`, `getBufferedMessages`, `processAndSummarize` 함수는 삭제하지 않고 유지. +새 시스템과 병행 운영 후 마이그레이션 완료 시 제거. + +**Step 3: 타입 체크** + +Run: `npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/summary-service.ts +git commit -m "feat: integrate smart context in AI response generation" +``` + +--- + +## Task 6: conversation-service.ts 수정 + +**Files:** +- Modify: `src/services/conversation-service.ts` + +**Step 1: 새 저장 시스템 사용** + +```typescript +import type { Env, KeyboardData } from '../types'; +import { + addToBuffer, + processAndSummarize, + generateAIResponse, +} from '../summary-service'; +import { saveConversationMessage } from './conversation-storage'; +import { sendChatAction } from '../telegram'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('conversation'); + +export interface ConversationResult { + responseText: string; + isProfileUpdated: boolean; + keyboardData?: KeyboardData | null; +} + +export class ConversationService { + constructor(private env: Env) {} + + async processUserMessage( + userId: number, + chatId: string, + text: string, + telegramUserId: string + ): Promise { + // 1. 타이핑 액션 전송 + sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(err => + logger.error('타이핑 액션 전송 실패', err as Error) + ); + + // 2. 새 시스템에 사용자 메시지 저장 + await saveConversationMessage(this.env.DB, telegramUserId, { + role: 'user', + content: text, + }); + + // 3. 기존 버퍼에도 저장 (호환성 - 나중에 제거) + await addToBuffer(this.env.DB, userId, chatId, 'user', text); + + // 4. AI 응답 생성 + let responseText = await generateAIResponse( + this.env, + userId, + chatId, + text, + telegramUserId + ); + + // __DIRECT__ 마커 제거 + if (responseText.includes('__DIRECT__')) { + const directIndex = responseText.indexOf('__DIRECT__'); + responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); + } + + // 5. 새 시스템에 봇 응답 저장 + await saveConversationMessage(this.env.DB, telegramUserId, { + role: 'assistant', + content: responseText, + }); + + // 6. 기존 버퍼에도 저장 (호환성) + await addToBuffer(this.env.DB, userId, chatId, 'bot', responseText); + + // 7. 기존 요약 프로세스 유지 (호환성) + const { summarized } = await processAndSummarize( + this.env, + userId, + chatId + ); + + // 8. 키보드 데이터 파싱 + let keyboardData: KeyboardData | null = null; + const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s); + + if (keyboardMatch) { + logger.debug('키보드 마커 감지', { preview: keyboardMatch[1].substring(0, 100) }); + responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, ''); + try { + keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData; + logger.debug('키보드 파싱 성공', { type: keyboardData.type }); + } catch (e) { + logger.error('키보드 파싱 오류', e as Error, { rawData: keyboardMatch[1] }); + } + } + + return { + responseText, + isProfileUpdated: summarized, + keyboardData + }; + } +} +``` + +**Step 2: 타입 체크** + +Run: `npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/services/conversation-service.ts +git commit -m "feat: save messages to new conversation storage" +``` + +--- + +## Task 7: 명령어 추가 (/history, /search) + +**Files:** +- Modify: `src/commands.ts` + +**Step 1: import 추가** + +```typescript +import { + getConversationHistory, + searchConversations, + getConversationStats, + extractKeywords +} from './services/conversation-storage'; +``` + +**Step 2: /history 명령어 추가** + +```typescript +case '/history': { + const limit = _args ? parseInt(_args, 10) : 20; + const validLimit = Math.min(Math.max(limit, 1), 100); // 1-100 제한 + + const messages = await getConversationHistory(env.DB, chatId, validLimit); + + if (messages.length === 0) { + return '📜 저장된 대화가 없습니다.'; + } + + const formatted = messages.map(m => { + const time = m.created_at + ? new Date(m.created_at).toLocaleString('ko-KR', { + timeZone: 'Asia/Seoul', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + : ''; + const role = m.role === 'user' ? '나' : '봇'; + const content = m.content.length > 50 + ? m.content.substring(0, 50) + '...' + : m.content; + return `[${time}] ${role}: ${content}`; + }).join('\n'); + + return `📜 최근 대화 (${messages.length}개)\n\n${formatted}\n\n/history 50 으로 더 보기`; +} + +case '/search': { + if (!_args || _args.trim().length < 2) { + return '🔍 검색어를 입력해주세요.\n예: /search 도메인'; + } + + const keywords = extractKeywords(_args); + if (keywords.length === 0) { + return '🔍 유효한 검색어가 없습니다.'; + } + + const results = await searchConversations(env.DB, chatId, keywords, 15); + + if (results.length === 0) { + return `🔍 "${_args}" 검색 결과가 없습니다.`; + } + + const formatted = results.map(m => { + const date = m.created_at + ? new Date(m.created_at).toLocaleDateString('ko-KR') + : ''; + const content = m.content.length > 60 + ? m.content.substring(0, 60) + '...' + : m.content; + return `[${date}] ${content}`; + }).join('\n'); + + return `🔍 "${_args}" 검색 결과 (${results.length}건)\n\n${formatted}`; +} +``` + +**Step 3: /stats 명령어 수정** + +기존 `/stats` 케이스를 수정하여 새 통계 포함: + +```typescript +case '/stats': { + const stats = await getConversationStats(env.DB, chatId); + + if (!stats) { + return '📈 아직 대화 기록이 없습니다.'; + } + + const firstDate = stats.first_message_at + ? new Date(stats.first_message_at).toLocaleDateString('ko-KR') + : '없음'; + const lastDate = stats.last_message_at + ? new Date(stats.last_message_at).toLocaleDateString('ko-KR') + : '없음'; + + return `📈 대화 통계 + +총 메시지: ${stats.message_count.toLocaleString()}개 +첫 대화: ${firstDate} +최근 대화: ${lastDate} +아카이브된 요약: ${stats.archived_summaries}개 + +/history - 대화 기록 보기 +/search 키워드 - 대화 검색`; +} +``` + +**Step 4: 타입 체크** + +Run: `npm run typecheck` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/commands.ts +git commit -m "feat: add /history and /search commands" +``` + +--- + +## Task 8: Cron에 아카이브 추가 + +**Files:** +- Modify: `src/index.ts` + +**Step 1: Cron 핸들러에 아카이브 추가** + +`index.ts`의 `scheduled` 핸들러 찾아서 아카이브 호출 추가: + +```typescript +// scheduled 핸들러 내부에 추가 +async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext) { + const scheduledLogger = createLogger('scheduled'); + + // 기존 로직... + + // UTC 15:00 (KST 00:00) - 아카이브 실행 + if (event.cron === '0 15 * * *') { + try { + const { archiveOldConversations } = await import('./services/archive-service'); + const result = await archiveOldConversations(env); + scheduledLogger.info('아카이브 완료', result); + } catch (error) { + scheduledLogger.error('아카이브 실패', error as Error); + } + } +} +``` + +**Step 2: 타입 체크** + +Run: `npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/index.ts +git commit -m "feat: add conversation archive to daily cron" +``` + +--- + +## Task 9: 기존 데이터 마이그레이션 스크립트 + +**Files:** +- Create: `scripts/migrate-conversations.ts` + +**Step 1: 마이그레이션 스크립트 작성** + +```typescript +/** + * 기존 message_buffer 데이터를 새 conversation 테이블로 마이그레이션 + * + * 실행: npx wrangler d1 execute telegram-conversations --local --file=scripts/migrate-conversations.sql + * 또는 Worker 내부에서 1회성 실행 + */ + +import type { Env } from '../src/types'; + +export async function migrateExistingConversations(env: Env): Promise<{ + migrated_users: number; + migrated_messages: number; + errors: string[]; +}> { + const db = env.DB; + const result = { + migrated_users: 0, + migrated_messages: 0, + errors: [] as string[], + }; + + // 1. 기존 message_buffer에서 사용자 목록 조회 + const { results: users } = await db + .prepare(` + SELECT DISTINCT u.telegram_id, mb.user_id + FROM message_buffer mb + JOIN users u ON mb.user_id = u.id + `) + .all<{ telegram_id: string; user_id: number }>(); + + if (!users || users.length === 0) { + console.log('마이그레이션 대상 없음'); + return result; + } + + // 2. 각 사용자별 마이그레이션 + for (const user of users) { + try { + const tableName = `conv_${user.telegram_id}`; + + // 테이블 생성 + await db.exec(` + CREATE TABLE IF NOT EXISTS ${tableName} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), + content TEXT NOT NULL, + tool_calls TEXT, + tool_results TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await db.exec(` + CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC) + `); + + // 메시지 복사 + const { results: messages } = await db + .prepare(` + SELECT role, message as content, created_at + FROM message_buffer + WHERE user_id = ? + ORDER BY created_at ASC + `) + .bind(user.user_id) + .all<{ role: string; content: string; created_at: string }>(); + + if (messages && messages.length > 0) { + for (const msg of messages) { + const role = msg.role === 'bot' ? 'assistant' : 'user'; + await db + .prepare(`INSERT INTO ${tableName} (role, content, created_at) VALUES (?, ?, ?)`) + .bind(role, msg.content, msg.created_at) + .run(); + } + + result.migrated_messages += messages.length; + } + + // 메타 테이블에 등록 + await db + .prepare(` + INSERT OR REPLACE INTO conversation_tables (telegram_id, table_name, message_count, last_message_at) + VALUES (?, ?, ?, (SELECT MAX(created_at) FROM ${tableName})) + `) + .bind(user.telegram_id, tableName, messages?.length || 0) + .run(); + + result.migrated_users++; + console.log(`마이그레이션 완료: ${user.telegram_id} (${messages?.length || 0}개)`); + + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + result.errors.push(`${user.telegram_id}: ${errMsg}`); + console.error(`마이그레이션 실패: ${user.telegram_id}`, error); + } + } + + console.log('전체 마이그레이션 결과:', result); + return result; +} +``` + +**Step 2: Commit** + +```bash +git add scripts/migrate-conversations.ts +git commit -m "feat: add migration script for existing conversations" +``` + +--- + +## Task 10: 테스트 및 배포 + +**Step 1: 로컬 테스트** + +```bash +# 1. 마이그레이션 SQL 적용 (로컬) +wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql + +# 2. 로컬 서버 실행 +npm run dev + +# 3. 테스트 메시지 전송 후 /history, /search, /stats 명령어 확인 +``` + +**Step 2: 타입 체크 & 빌드** + +```bash +npm run typecheck +npm run build # 또는 wrangler deploy --dry-run +``` +Expected: PASS + +**Step 3: 프로덕션 마이그레이션 적용** + +```bash +wrangler d1 execute telegram-conversations --file=migrations/008_conversation_tables.sql +``` + +**Step 4: 배포** + +```bash +npm run deploy +``` + +**Step 5: Commit (최종)** + +```bash +git add . +git commit -m "feat: complete conversation storage system implementation" +``` + +--- + +## 완료 후 검증 체크리스트 + +- [ ] `/history` 명령어가 최근 대화를 표시하는가 +- [ ] `/search 키워드` 명령어가 관련 대화를 찾는가 +- [ ] `/stats` 명령어가 통계를 표시하는가 +- [ ] 새 메시지가 `conv_{telegram_id}` 테이블에 저장되는가 +- [ ] AI 응답이 스마트 컨텍스트를 사용하는가 +- [ ] 기존 기능 (명령어, 도메인, 서버, 예치금)이 정상 동작하는가 + +--- + +## 롤백 계획 + +1. **코드 롤백**: `conversation-service.ts`에서 `saveConversationMessage` 호출 제거 +2. **DB 유지**: 새 테이블들은 독립적이므로 삭제 불필요 +3. **기존 시스템**: `message_buffer` + `summaries`가 그대로 작동 diff --git a/docs/plans/2026-02-05-remove-server-recommendation.md b/docs/plans/2026-02-05-remove-server-recommendation.md new file mode 100644 index 0000000..7c26624 --- /dev/null +++ b/docs/plans/2026-02-05-remove-server-recommendation.md @@ -0,0 +1,533 @@ +# 서버 추천 기능 제거 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 서버 추천/상담 기능을 제거하고, 서버 관리 기능(목록, 시작/중지, 삭제 등)만 유지 + +**Architecture:** 진입점(openai-service.ts, message-handler.ts)에서 상담 세션 분기 제거 → server-agent.ts 삭제 → server-tool.ts에서 추천 관련 action 제거 → types.ts 정리 → constants 정리 → DB 마이그레이션 + +**Tech Stack:** TypeScript, Cloudflare Workers, D1 SQLite + +--- + +## Task 1: openai-service.ts - 서버 상담 세션 분기 제거 + +**Files:** +- Modify: `src/openai-service.ts:9` (import), `src/openai-service.ts:211-250` (session check) + +**Step 1: import 제거** + +```typescript +// 제거할 라인 (줄 9) +import { hasServerSession, processServerConsultation } from './agents/server-agent'; +``` + +**Step 2: 서버 상담 세션 체크 블록 제거** + +줄 211-250의 서버 세션 체크 블록 전체 삭제: +```typescript +// 이 전체 블록 삭제 (줄 211-250 근처) +// Check if server consultation session is active +if (telegramUserId && env.DB) { + try { + const hasSession = await hasServerSession(env.DB, telegramUserId); + + if (hasSession) { + logger.info('Active server session detected, routing to consultation', { + userId: telegramUserId + }); + + // Create callback for intermediate messages + let sendIntermediateMessage: ((message: string) => Promise) | undefined; + if (chatIdStr) { + sendIntermediateMessage = async (message: string) => { + logger.info('Sending intermediate message', { chatId: chatIdStr, messagePreview: message.substring(0, 50) }); + await sendMessage(env.BOT_TOKEN, parseInt(chatIdStr), message); + logger.info('Intermediate message sent successfully', { chatId: chatIdStr }); + }; + } + + const result = await processServerConsultation( + env.DB, + telegramUserId, + userMessage, + env, + { sendIntermediateMessage } + ); + + // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 + if (result !== '__PASSTHROUGH__') { + return result; + } + // Continue to normal flow below + } + } catch (error) { + logger.error('Session check failed, continuing with normal flow', error as Error, { + telegramUserId + }); + // Continue with normal flow if session check fails + } + // ... (troubleshoot, domain, deposit, ddos 세션 체크는 유지) +``` + +**Step 3: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: 서버 세션 관련 에러들 발생 (다음 태스크에서 해결) + +**Step 4: Commit** + +```bash +git add src/openai-service.ts +git commit -m "refactor: remove server consultation session routing from openai-service + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 2: message-handler.ts - 서버 신청 관련 코드 정리 + +**Files:** +- Modify: `src/routes/handlers/message-handler.ts:117-218` + +**Step 1: "신청" 처리에서 서버 세션 의존 코드 제거** + +줄 117-218의 서버 신청 확인 처리 블록 전체 삭제 (서버 관리는 manage_server 도구로 직접 처리): + +```typescript +// 이 전체 블록 삭제 (줄 117-218) +// 7. 서버 신청 확인 처리 (텍스트 기반) - Queue 기반 +if (text.trim() === '신청') { + if (orderSessionData) { + // ... 전체 블록 + } +} +``` + +**Step 2: orderSessionKey 변수 및 Promise.all에서 제거** + +```typescript +// 변경 전 (줄 60-66) +const deleteSessionKey = `delete_confirm:${telegramUserId}`; +const orderSessionKey = `server_order_confirm:${telegramUserId}`; + +const [deleteSessionData, orderSessionData] = await Promise.all([ + env.SESSION_KV.get(deleteSessionKey), + env.SESSION_KV.get(orderSessionKey), +]); + +// 변경 후 +const deleteSessionKey = `delete_confirm:${telegramUserId}`; + +const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey); +``` + +**Step 3: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: PASS 또는 server-agent 관련 에러 + +**Step 4: Commit** + +```bash +git add src/routes/handlers/message-handler.ts +git commit -m "refactor: remove server order confirmation from message-handler + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 3: server-agent.ts 파일 삭제 + +**Files:** +- Delete: `src/agents/server-agent.ts` + +**Step 1: 파일 삭제** + +```bash +rm src/agents/server-agent.ts +``` + +**Step 2: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: server-tool.ts에서 import 에러 발생 (다음 태스크에서 해결) + +**Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: delete server-agent.ts (905 lines) + +Remove server recommendation consultation system: +- 30-year expert AI persona +- Session-based information gathering +- Brave Search / Context7 tool integration +- Automatic spec inference + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 4: server-tool.ts - 추천 관련 action 제거 + +**Files:** +- Modify: `src/tools/server-tool.ts` + +**Step 1: 추천 관련 함수 제거** + +다음 함수들 삭제: +- `getRecommendationData()` (줄 332-374) +- `formatRecommendations()` (줄 378-431) +- `estimateCdnCacheHitRate()` (줄 42-66) +- `CDN_CACHE_HIT_RATES` 상수 (줄 24-31) + +**Step 2: manageServerTool 정의에서 추천 관련 enum 값 제거** + +```typescript +// 변경 전 (줄 96-99) +action: { + type: 'string', + enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot', + 'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename'], + description: '...', +} + +// 변경 후 +action: { + type: 'string', + enum: ['order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot', 'rename'], + description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, order: 서버 주문, rename: 이름 변경, images: OS 이미지 목록', +} +``` + +**Step 3: 추천 관련 파라미터 제거** + +manageServerTool.function.parameters.properties에서 제거: +- `tech_stack` +- `expected_users` +- `use_case` +- `traffic_pattern` +- `region_preference` +- `budget_limit` +- `lang` +- `message` + +**Step 4: executeServerAction에서 추천 관련 case 제거** + +삭제할 case들: +- `case 'start_consultation':` (줄 665-678) +- `case 'continue_consultation':` (줄 680-697) +- `case 'cancel_consultation':` (줄 699-717) +- `case 'recommend':` (줄 719-825) + +**Step 5: executeServerAction에서 server-agent import 제거** + +```typescript +// 삭제할 dynamic import (case 'continue_consultation' 내부) +const { processServerConsultation } = await import('../agents/server-agent'); + +// 삭제할 dynamic import (case 'cancel_consultation' 내부) +const { ServerSessionManager } = await import('../utils/session-manager'); +const { getSessionConfig } = await import('../constants/agent-config'); +``` + +**Step 6: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: PASS + +**Step 7: Commit** + +```bash +git add src/tools/server-tool.ts +git commit -m "refactor: remove recommendation actions from server-tool + +Removed: +- start_consultation, continue_consultation, cancel_consultation, recommend actions +- getRecommendationData(), formatRecommendations() +- CDN cache hit rate estimation +- Recommendation-related parameters (tech_stack, expected_users, etc.) + +Retained: +- order, list, info, delete, images, start, stop, reboot, rename actions + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 5: types.ts - 서버 세션 관련 타입 제거 + +**Files:** +- Modify: `src/types.ts` + +**Step 1: ServerSession 관련 타입 제거** + +삭제할 타입들: +- `ServerSessionStatus` (줄 248-253) +- `ServerSession` (줄 256-293) + +**Step 2: ManageServerArgs에서 추천 관련 필드 제거** + +```typescript +// 변경 전 (줄 212-239) +export interface ManageServerArgs { + action: + | "recommend" + | "order" + | "start" + | "stop" + | "delete" + | "list" + | "info" + | "images" + | "start_consultation" + | "continue_consultation" + | "cancel_consultation"; + tech_stack?: string[]; + expected_users?: number; + use_case?: string; + traffic_pattern?: string; + region_preference?: string[]; + budget_limit?: number; + lang?: string; + // ... 나머지 +} + +// 변경 후 +export interface ManageServerArgs { + action: + | "order" + | "start" + | "stop" + | "delete" + | "list" + | "info" + | "images" + | "reboot" + | "rename"; + server_id?: string; + region_code?: string; + label?: string; + pricing_id?: number; + order_id?: number; + new_label?: string; + image?: string; +} +``` + +**Step 3: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/types.ts +git commit -m "refactor: remove ServerSession types from types.ts + +Removed: +- ServerSessionStatus type +- ServerSession interface +- Recommendation-related fields from ManageServerArgs + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 6: constants/index.ts - SERVER_CONSULTATION_STATUS 제거 + +**Files:** +- Modify: `src/constants/index.ts` + +**Step 1: SERVER_CONSULTATION_STATUS 상수 제거** + +```typescript +// 삭제 (줄 157-162) +export const SERVER_CONSULTATION_STATUS = { + GATHERING: 'gathering', + RECOMMENDING: 'recommending', + SELECTING: 'selecting', + COMPLETED: 'completed', +} as const; +``` + +**Step 2: 타입 export 제거** + +```typescript +// 삭제 (줄 220) +export type ServerConsultationStatus = typeof SERVER_CONSULTATION_STATUS[keyof typeof SERVER_CONSULTATION_STATUS]; +``` + +**Step 3: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/constants/index.ts +git commit -m "refactor: remove SERVER_CONSULTATION_STATUS from constants + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 7: commands.ts - /server 명령어 응답 수정 + +**Files:** +- Modify: `src/commands.ts:268-274` + +**Step 1: "서버 추천" 안내 문구 제거** + +```typescript +// 변경 전 (줄 268-274) +if (servers.length === 0) { + return `🖥️ 내 서버 + +보유한 서버가 없습니다. + +"서버 추천" 또는 "서버 신청"으로 시작하세요!`; +} + +// 변경 후 +if (servers.length === 0) { + return `🖥️ 내 서버 + +보유한 서버가 없습니다.`; +} +``` + +**Step 2: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/commands.ts +git commit -m "refactor: update /server command to remove recommendation guide + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 8: D1 마이그레이션 - server_sessions 테이블 DROP + +**Files:** +- Create: `migrations/006_drop_server_sessions.sql` +- Modify: `schema.sql` (server_sessions 정의 제거) + +**Step 1: 마이그레이션 파일 생성** + +```sql +-- migrations/006_drop_server_sessions.sql +-- Drop server consultation sessions table +-- This table was used for server recommendation consultation feature which is now removed + +DROP TABLE IF EXISTS server_sessions; +DROP INDEX IF EXISTS idx_server_sessions_expires; +``` + +**Step 2: schema.sql에서 server_sessions 제거** + +```sql +-- 삭제할 부분 (줄 89-99) +-- 서버 상담 세션 테이블 +CREATE TABLE IF NOT EXISTS server_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('gathering', 'recommending', 'selecting', 'ordering', 'completed')), + collected_info TEXT, + last_recommendation TEXT, + messages TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- 인덱스도 삭제 (줄 156) +CREATE INDEX IF NOT EXISTS idx_server_sessions_expires ON server_sessions(expires_at); +``` + +**Step 3: 로컬 마이그레이션 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && wrangler d1 execute telegram-conversations --local --file=migrations/006_drop_server_sessions.sql` +Expected: 성공 + +**Step 4: Commit** + +```bash +git add migrations/006_drop_server_sessions.sql schema.sql +git commit -m "refactor: drop server_sessions table + +Add migration to remove server consultation sessions table. +Update schema.sql to remove table definition. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Task 9: 최종 검증 + +**Step 1: 타입체크** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run typecheck` +Expected: PASS (에러 0개) + +**Step 2: 빌드** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run build` +Expected: PASS + +**Step 3: 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm test` +Expected: PASS (또는 서버 추천 관련 테스트만 실패) + +**Step 4: 로컬 서버 테스트** + +Run: `cd /Users/kaffa/Projects/bots/telegram-bot-workers && npm run dev` + +테스트 항목: +1. `/server` 명령어 - 서버 목록 표시 확인 +2. "서버 추천해줘" 입력 - 상담 시작 안 됨 확인 (일반 AI 응답) +3. 서버 시작/중지/삭제 (기존 서버 있는 경우) + +**Step 5: 최종 커밋 (선택)** + +```bash +git add -A +git commit -m "chore: final cleanup after server recommendation removal + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Summary + +| Task | 파일 | 변경 내용 | +|------|------|----------| +| 1 | openai-service.ts | 서버 세션 import 및 라우팅 제거 | +| 2 | message-handler.ts | "신청" 처리 블록 제거 | +| 3 | server-agent.ts | 파일 삭제 (905줄) | +| 4 | server-tool.ts | 추천 action 4개 + 헬퍼 함수 제거 | +| 5 | types.ts | ServerSession 타입 제거 | +| 6 | constants/index.ts | SERVER_CONSULTATION_STATUS 제거 | +| 7 | commands.ts | /server 안내 문구 수정 | +| 8 | schema.sql + migration | server_sessions 테이블 DROP | +| 9 | - | 최종 검증 | + +**예상 제거 라인:** ~1,200줄 +**유지 기능:** 서버 관리 (list, info, start, stop, reboot, delete, rename, order, images) diff --git a/migrations/010_add_ddos_sessions.sql b/migrations/010_add_ddos_sessions.sql new file mode 100644 index 0000000..2e17738 --- /dev/null +++ b/migrations/010_add_ddos_sessions.sql @@ -0,0 +1,19 @@ +-- Migration: Add DDoS Defense Sessions Table +-- Created: 2026-02-05 +-- Description: Stores DDoS defense consultation sessions + +CREATE TABLE IF NOT EXISTS ddos_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('gathering', 'analyzing', 'recommending', 'completed')), + collected_info TEXT, -- JSON: { attack_type?: string, target?: string, symptoms?: string[], traffic_volume?: string } + messages TEXT, -- JSON: [{ role: 'user' | 'assistant', content: string }] + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Index for cleanup queries (expired sessions) +CREATE INDEX IF NOT EXISTS idx_ddos_sessions_expires_at ON ddos_sessions(expires_at); + +-- Index for status queries (optional, for analytics) +CREATE INDEX IF NOT EXISTS idx_ddos_sessions_status ON ddos_sessions(status); diff --git a/migrations/011_fix_ddos_session_status.sql b/migrations/011_fix_ddos_session_status.sql new file mode 100644 index 0000000..598c22e --- /dev/null +++ b/migrations/011_fix_ddos_session_status.sql @@ -0,0 +1,37 @@ +-- Migration: Fix DDoS Session Status Constraint +-- Created: 2026-02-05 +-- Description: Update status check constraint to match TypeScript types + +-- SQLite doesn't support ALTER TABLE to modify CHECK constraints +-- So we need to recreate the table with correct constraint + +-- Step 1: Create new table with correct constraint +CREATE TABLE IF NOT EXISTS ddos_sessions_new ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('gathering', 'analyzing', 'mitigating', 'monitoring', 'completed')), + collected_info TEXT, + messages TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Step 2: Copy data from old table (if exists) +INSERT OR IGNORE INTO ddos_sessions_new +SELECT user_id, + CASE + WHEN status = 'recommending' THEN 'analyzing' + ELSE status + END as status, + collected_info, messages, created_at, updated_at, expires_at +FROM ddos_sessions; + +-- Step 3: Drop old table +DROP TABLE IF EXISTS ddos_sessions; + +-- Step 4: Rename new table +ALTER TABLE ddos_sessions_new RENAME TO ddos_sessions; + +-- Step 5: Recreate indexes +CREATE INDEX IF NOT EXISTS idx_ddos_sessions_expires_at ON ddos_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_ddos_sessions_status ON ddos_sessions(status); diff --git a/src/agents/ddos-agent.ts b/src/agents/ddos-agent.ts new file mode 100644 index 0000000..897b887 --- /dev/null +++ b/src/agents/ddos-agent.ts @@ -0,0 +1,536 @@ +/** + * DDoS Defense Agent - 보안 전문가 AI + * + * 기능: + * - 대화형 DDoS 공격 분석 및 방어 + * - 세션 기반 정보 수집 (D1) + * - Cloudflare/서버 방화벽 방어 조치 (STUB) + * - 실시간 상태 모니터링 (STUB) + * + * Manual Test: + * 1. User: "사이트가 DDoS 공격 받고 있어" + * 2. Expected: 증상 파악 → 분석 → 방어 권장사항 + * 3. User: "방어 적용해줘" + * 4. Expected: 방어 조치 적용 (STUB) + */ + +import type { Env, DdosSession, OpenAIToolCall, OpenAIAPIResponse } from '../types'; +import { createLogger } from '../utils/logger'; +import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool'; +import { SessionManager } from '../utils/session-manager'; +import { getSessionConfig, AI_CONFIG } from '../constants/agent-config'; +import { + analyzeTraffic, + detectAttackType, + getProtectionStatus, + applyRecommendedMitigation, + activateEmergencyDefense, +} from '../services/ddos-defense-service'; + +const logger = createLogger('ddos-agent'); + +// Session manager instance +const sessionManager = new SessionManager(getSessionConfig('ddos')); + +/** + * DDoS 세션 존재 여부 확인 (라우팅용) + */ +export async function hasDdosSession(db: D1Database, userId: string): Promise { + return await sessionManager.has(db, userId); +} + +// DDoS Defense Expert System Prompt +const DDOS_EXPERT_PROMPT = `당신은 친절한 보안 도우미입니다. 사이트 장애나 공격 상황에서 사용자를 돕습니다. + +## 대화 스타일 +- 따뜻하고 안심시키는 어조 (걱정 마세요, 도와드릴게요) +- 한 번에 하나씩만 질문 (절대 여러 개 동시에 묻지 않기) +- 전문 용어는 피하고, 꼭 필요하면 쉽게 설명 +- 짧고 명확한 문장 +- 사용자가 모르면 괜찮다고 안심시키기 + +## 정보 수집 순서 (자연스럽게, 대화 흐름에 따라) +1. 증상 파악: "어떤 증상이 나타나고 있나요?" +2. 대상 확인: "어떤 사이트(또는 서버)인가요?" +3. 필요시 추가 질문 (한 번에 하나씩) + +## 중요 규칙 +- 사용자가 답을 모르면: "괜찮아요, 다른 방법으로 확인해볼게요" +- 기술적 정보를 한꺼번에 요구하지 않기 +- 상황이 파악되면 바로 도움 제공 +- 방어 조치 전 항상 "이렇게 해볼까요?" 확인 + +## 도구 사용 +- 증상/대상 파악 후 → analyze_attack +- 현재 상태 궁금하면 → check_protection_status +- 조치 적용 동의 시 → apply_mitigation +- 긴급 상황 시 → activate_emergency + +## 특수 지시 +- 사이트 장애/공격과 무관한 메시지 → "__PASSTHROUGH__"만 응답 +- 문제 해결 또는 종료 요청 시 → "__SESSION_END__"를 응답 끝에 추가 + +## 현재 상태 +방어 시스템 연동 준비 중입니다. 지금은: +- 상황 분석 및 조언 가능 +- 실제 방어 조치는 시뮬레이션으로 안내`; + +// DDoS Defense Tools for Function Calling +const DDOS_TOOLS = [ + { + type: 'function' as const, + function: { + name: 'analyze_attack', + description: 'DDoS 공격 패턴을 분석합니다. 증상, 트래픽 패턴, 공격 유형을 파악합니다.', + parameters: { + type: 'object', + properties: { + target: { + type: 'string', + description: '공격 대상 (도메인, IP, 서비스명)', + }, + symptoms: { + type: 'string', + description: '관찰된 증상 (예: 응답 지연, 접속 불가, 높은 트래픽)', + }, + }, + required: ['target'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'check_protection_status', + description: '현재 DDoS 방어 상태를 확인합니다. 활성화된 규칙, 차단된 요청 수 등을 조회합니다.', + parameters: { + type: 'object', + properties: { + target: { + type: 'string', + description: '확인할 대상 (도메인 또는 서버)', + }, + }, + required: ['target'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'apply_mitigation', + description: '권장 방어 조치를 적용합니다. Cloudflare WAF, Rate Limiting, IP 차단 등의 조치를 실행합니다.', + parameters: { + type: 'object', + properties: { + target: { + type: 'string', + description: '방어 대상', + }, + mitigation_type: { + type: 'string', + enum: ['rate_limiting', 'waf_rule', 'ip_block', 'under_attack_mode', 'auto'], + description: '적용할 방어 유형 (auto는 분석 결과 기반 자동 선택)', + }, + severity: { + type: 'string', + enum: ['low', 'medium', 'high', 'critical'], + description: '공격 심각도', + }, + }, + required: ['target', 'mitigation_type'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'activate_emergency', + description: '긴급 방어 모드를 활성화합니다. 심각한 공격 상황에서 최대 방어 조치를 즉시 적용합니다.', + parameters: { + type: 'object', + properties: { + target: { + type: 'string', + description: '긴급 방어 대상', + }, + reason: { + type: 'string', + description: '긴급 방어 사유', + }, + }, + required: ['target', 'reason'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'search_ddos_solutions', + description: 'Brave Search로 최신 DDoS 방어 기법, 사례, 해결책을 검색합니다.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: '검색 쿼리 (영문 권장, 예: "cloudflare under attack mode setup")', + }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'lookup_security_docs', + description: 'Cloudflare, nginx 등 보안 관련 공식 문서를 조회합니다.', + parameters: { + type: 'object', + properties: { + service: { + type: 'string', + description: '서비스명 (예: cloudflare, nginx, iptables)', + }, + topic: { + type: 'string', + description: '조회할 주제 (예: ddos protection, rate limiting, waf)', + }, + }, + required: ['service', 'topic'], + }, + }, + }, +]; + +// Execute DDoS defense tool +async function executeDdosTool( + toolName: string, + args: Record, + session: DdosSession, + env: Env +): Promise { + logger.info('도구 실행', { toolName, args }); + + switch (toolName) { + case 'analyze_attack': { + const target = typeof args.target === 'string' ? args.target : ''; + if (!target) { + return JSON.stringify({ error: 'target이 필요합니다' }); + } + const symptoms = typeof args.symptoms === 'string' ? args.symptoms : ''; + + // Detect attack type from symptoms + const attackType = detectAttackType(symptoms); + + // Update session with collected info + session.collected_info.target = target; + session.collected_info.symptoms = symptoms; + session.collected_info.attack_type = attackType; + + // Analyze traffic (STUB) + const analysis = await analyzeTraffic(target, env); + + return JSON.stringify({ + target, + detected_attack_type: attackType, + analysis, + message: '공격 분석 완료. 권장 조치를 확인하세요.', + }); + } + + case 'check_protection_status': { + const target = typeof args.target === 'string' ? args.target : ''; + if (!target) { + return JSON.stringify({ error: 'target이 필요합니다' }); + } + const status = await getProtectionStatus(target, env); + return JSON.stringify(status); + } + + case 'apply_mitigation': { + const target = typeof args.target === 'string' ? args.target : ''; + if (!target) { + return JSON.stringify({ error: 'target이 필요합니다' }); + } + const mitigationType = typeof args.mitigation_type === 'string' ? args.mitigation_type : 'auto'; + const severity = typeof args.severity === 'string' ? args.severity : 'medium'; + + const analysis = { + attack_type: session.collected_info.attack_type || 'unknown', + severity: severity as 'low' | 'medium' | 'high' | 'critical', + estimated_volume: session.collected_info.traffic_volume || 'N/A', + source_analysis: '', + recommendations: [], + }; + + const result = await applyRecommendedMitigation( + analysis, + target, + session.collected_info.provider || 'cloudflare', + env + ); + + return JSON.stringify({ + mitigation_type: mitigationType, + ...result, + }); + } + + case 'activate_emergency': { + const target = typeof args.target === 'string' ? args.target : ''; + if (!target) { + return JSON.stringify({ error: 'target이 필요합니다' }); + } + const reason = typeof args.reason === 'string' ? args.reason : '긴급 상황'; + + logger.warn('긴급 방어 모드 요청', { target, reason }); + + const result = await activateEmergencyDefense(target, env); + return JSON.stringify(result); + } + + case 'search_ddos_solutions': { + const query = args.query as string; + const result = await executeSearchWeb({ query }, env); + return result; + } + + case 'lookup_security_docs': { + const service = args.service as string; + const topic = args.topic as string; + const result = await executeLookupDocs({ library: service, query: topic }, env); + return result; + } + + default: + return `알 수 없는 도구: ${toolName}`; + } +} + +/** + * DDoS Expert AI 호출 (Function Calling 지원) + */ +async function callDdosExpertAI( + session: DdosSession, + userMessage: string, + env: Env +): Promise<{ response: string; calledTools: string[] }> { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY not configured'); + } + + const { getOpenAIUrl } = await import('../utils/api-urls'); + + // Build conversation history + const conversationHistory = session.messages.map(m => ({ + role: m.role === 'user' ? 'user' as const : 'assistant' as const, + content: m.content, + })); + + const systemPrompt = `${DDOS_EXPERT_PROMPT} + +## 현재 수집된 정보 +${JSON.stringify(session.collected_info, null, 2)}`; + + try { + const messages: Array<{ + role: string; + content: string | null; + tool_calls?: OpenAIToolCall[]; + tool_call_id?: string; + name?: string + }> = [ + { role: 'system', content: systemPrompt }, + ...conversationHistory, + { role: 'user', content: userMessage }, + ]; + + const MAX_TOOL_CALL_ROUNDS = 3; + let toolCallRound = 0; + const calledTools: string[] = []; + + // Loop to handle tool calls + while (toolCallRound < MAX_TOOL_CALL_ROUNDS) { + const requestBody = { + model: AI_CONFIG.model, + messages, + tools: DDOS_TOOLS, + tool_choice: 'auto', + max_tokens: AI_CONFIG.maxTokens.ddos, + temperature: AI_CONFIG.temperature.ddos, + }; + + const response = await fetch(getOpenAIUrl(env), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${error}`); + } + + const data = await response.json() as OpenAIAPIResponse; + const assistantMessage = data.choices[0].message; + + // Check if AI wants to call tools + if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { + logger.info('도구 호출 요청', { + tools: assistantMessage.tool_calls.map(tc => tc.function.name), + }); + + // Add assistant message with tool calls + messages.push({ + role: 'assistant', + content: assistantMessage.content, + tool_calls: assistantMessage.tool_calls, + }); + + // Execute each tool and add results + for (const toolCall of assistantMessage.tool_calls) { + let args: Record; + try { + args = JSON.parse(toolCall.function.arguments); + } catch (parseError) { + logger.error('도구 인자 JSON 파싱 실패', parseError as Error, { + toolName: toolCall.function.name, + arguments: toolCall.function.arguments?.slice(0, 200), + }); + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: JSON.stringify({ error: '도구 인자 파싱 실패' }), + }); + continue; + } + + const result = await executeDdosTool(toolCall.function.name, args, session, env); + + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: result, + }); + + // Track which tools were called + calledTools.push(toolCall.function.name); + } + + // Count this round of tool calls + toolCallRound++; + + // Continue loop to get AI's response with tool results + continue; + } + + // No tool calls - return final response + const aiResponse = assistantMessage.content || ''; + logger.info('AI 응답', { response: aiResponse.slice(0, 200) }); + + // Check for special markers + if (aiResponse.includes('__PASSTHROUGH__')) { + return { response: '__PASSTHROUGH__', calledTools }; + } + + // Check for session end marker + const sessionEnd = aiResponse.includes('__SESSION_END__'); + const cleanResponse = aiResponse.replace('__SESSION_END__', '').trim(); + + return { + response: sessionEnd ? `${cleanResponse}\n\n[세션 종료]` : cleanResponse, + calledTools, + }; + } + + // Max tool call rounds reached + logger.warn('최대 도구 호출 라운드 도달', { toolCallRound, totalToolsCalled: calledTools.length }); + return { + response: '수집한 정보를 바탕으로 방어 권장사항을 제시해드리겠습니다.', + calledTools, + }; + } catch (error) { + logger.error('DDoS Expert AI 호출 실패', error as Error); + throw error; + } +} + +/** + * DDoS 방어 상담 처리 (메인 함수) + */ +export async function processDdosConsultation( + db: D1Database, + userId: string, + userMessage: string, + env: Env +): Promise { + const startTime = Date.now(); + logger.info('DDoS 방어 상담 시작', { userId, message: userMessage.substring(0, 100) }); + + try { + // 1. Check for existing session + let session = await sessionManager.get(db, userId); + + // 2. Create new session if none exists + if (!session) { + session = sessionManager.create(userId, 'gathering'); + } + + // 3. Add user message to session + sessionManager.addMessage(session, 'user', userMessage); + + // 4. Call AI to get response and possible tool calls + const aiResult = await callDdosExpertAI(session, userMessage, env); + + // 5. Handle __PASSTHROUGH__ - not DDoS related + if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) { + logger.info('DDoS 상담 패스스루', { userId }); + return '__PASSTHROUGH__'; + } + + // 6. Handle __SESSION_END__ - session complete + if (aiResult.response.includes('[세션 종료]')) { + logger.info('DDoS 상담 세션 종료', { userId }); + await sessionManager.delete(db, userId); + return aiResult.response.replace('[세션 종료]', '').trim(); + } + + // 7. Add assistant response to session and save + sessionManager.addMessage(session, 'assistant', aiResult.response); + + // Update session status based on which tools were called + for (const toolName of aiResult.calledTools) { + if (toolName === 'analyze_attack') { + session.status = 'analyzing'; + break; + } else if (toolName === 'apply_mitigation' || toolName === 'activate_emergency') { + session.status = 'mitigating'; + break; + } else if (toolName === 'check_protection_status') { + session.status = 'monitoring'; + break; + } + } + + session.updated_at = Date.now(); + await sessionManager.save(db, session); + + logger.info('DDoS 방어 상담 완료', { + userId, + duration: Date.now() - startTime, + status: session.status + }); + + return aiResult.response; + + } catch (error) { + logger.error('DDoS 방어 상담 오류', error as Error, { userId }); + return '죄송합니다. DDoS 방어 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } +} diff --git a/src/agents/server-agent.ts b/src/agents/server-agent.ts deleted file mode 100644 index 702ca98..0000000 --- a/src/agents/server-agent.ts +++ /dev/null @@ -1,905 +0,0 @@ -/** - * Server Expert Agent - 서버 전문가 AI 상담 시스템 - * - * 기능: - * - 대화형 서버 추천 상담 - * - 세션 기반 정보 수집 (D1) - * - 충분한 정보 수집 시 자동 추천 - * - 추천 후 사용자 선택 및 주문 흐름 - * - Brave Search / Context7 도구로 최신 트렌드 반영 - * - * Manual Test: - * 1. User: "서버 추천" - * 2. Expected: Category detection → 1-2 questions → Recommendation - * 3. User: "1번" - * 4. Expected: Order confirmation - */ - -import type { Env, ServerSession, BandwidthInfo, RecommendResponse, OpenAIToolCall, OpenAIAPIResponse } from '../types'; -import { createLogger } from '../utils/logger'; -import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool'; -import { formatTrafficInfo } from '../utils/formatters'; -import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from '../constants'; -import { ServerSessionManager } from '../utils/session-manager'; -import { getSessionConfig } from '../constants/agent-config'; - -const logger = createLogger('server-agent'); - -// Session manager instance -const sessionManager = new ServerSessionManager(getSessionConfig('server')); - -/** - * 서버 세션 존재 여부 확인 (라우팅용) - * - * @param db - D1 Database - * @param userId - Telegram User ID - * @returns true if active session exists, false otherwise - */ -export async function hasServerSession(db: D1Database, userId: string): Promise { - return await sessionManager.has(db, userId); -} - -/** - * 만료된 서버 세션 정리 (Cron 또는 수동 실행) - * - * @param db - D1 Database - * @returns 삭제된 세션 개수 - */ -export async function cleanupExpiredSessions(db: D1Database): Promise { - try { - const result = await db.prepare( - 'DELETE FROM server_sessions WHERE expires_at < ?' - ).bind(Date.now()).run(); - - const deleted = result.meta.changes || 0; - if (deleted > 0) { - logger.info('만료 세션 정리', { deleted }); - } - return deleted; - } catch (error) { - logger.error('만료 세션 정리 실패', error as Error); - return 0; - } -} - -// Server Expert System Prompts -const SERVER_EXPERT_PROMPT = `당신은 30년 경력의 시니어 클라우드 아키텍트입니다. - -## 전문성 (30년 경력) -- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터 -- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문 -- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험 -- 수천 개의 서버 구축 경험으로 용도만 들으면 최적 스펙을 바로 판단 가능 - -## 성격 -- 따뜻하고 친근하지만 전문적인 어조 -- 비기술자도 이해하기 쉽게 설명 -- 고객의 예산과 상황을 항상 배려 -- 불필요한 기술 용어 사용 자제 - -## 금지 사항 (절대 위반 금지) -- AWS, GCP, Azure, Vultr, Linode, DigitalOcean 등 다른 클라우드 프로바이더 언급 금지 -- 경쟁사 서비스 추천 금지 -- 우리 서비스(Anvil)만 추천 -- "다른 곳도 고려해보세요" 같은 멘트 금지 - -## 도구 사용 가이드 (적극적으로 활용할 것) -- 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인 -- "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출 -- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변 -- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...") - -## 대화 흐름 -1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)" -2. 규모 파악: "개인용인가요, 사업용인가요?" -3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?" -4. 정보가 충분하면 즉시 추천 (추가 질문 없이) - -## 핵심 규칙 (반드시 준수) -- 기술 스택, 트래픽 패턴은 절대 묻지 않음 (30년 경험으로 알아서 추론) -- 사용자 수를 언급하면 DAU인지 동시접속자인지 반드시 한 번 확인 -- "방문자 1000명", "유저 500명" 등 언급 시 → "말씀하신 방문자는 일일 방문자(DAU)인가요, 동시접속자인가요?" -- DAU와 동시접속자를 구분해서 설명: "일반적으로 동시접속자는 일일 방문자의 5-10% 정도입니다" -- "모르겠어요", "아무거나", "글쎄요" → 즉시 action="recommend" (기본값: 개인용 웹서비스) -- 용도+규모 한번에 말하면 → 즉시 action="recommend" -- 용도만 말해도 → 개인용으로 가정하고 action="recommend" 가능 -- 질문은 최대 2번까지, 그 이후는 무조건 action="recommend" - -## 사용자 수 관련 용어 정리 -- **DAU (일일 활성 사용자)**: 하루 동안 서비스를 사용하는 전체 사용자 수 -- **동시접속자 (Concurrent Users)**: 같은 시간에 동시에 접속해 있는 사용자 수 -- **중요**: 서버 스펙은 동시접속자를 기준으로 계산해야 합니다 -- **일반 공식**: 동시접속자 = DAU × 5-10% - -예시: -- "하루 방문자 1000명" → DAU 1000명 → 동시접속자 50-100명 -- "동시 접속 100명" → 그대로 동시접속자 100명 사용 - -## 추론 규칙 (30년 경험 기반) -- 블로그 → WordPress, 1GB RAM이면 충분, DAU 100명 (동시접속자 10명) -- 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명) -- 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB -- 게임서버 → 고사양 CPU, 낮은 레이턴시 리전 -- SaaS/B2B/Enterprise → 최소 4GB+ RAM, PostgreSQL+Redis 권장, 500명+ 동시접속 가정 -- API 서버 → 트래픽에 따라 2~8GB, Redis 캐시 권장 -- 실시간 서비스 (WebSocket) → 최소 4GB RAM, Redis 권장 -- 고성능 DB (PostgreSQL, MongoDB) → 최소 4GB+ RAM, 높은 IOPS -- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명), SaaS→DAU 2000명 (동접 200명) - -## 특수 지시 -- 서버/호스팅과 무관한 메시지가 들어오면 반드시 "__PASSTHROUGH__"만 응답 -- 상담 종료가 필요하면 "__SESSION_END__"를 응답 끝에 추가`; - -const SERVER_REVIEW_PROMPT = `당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다. - -## 전문성 (30년 경력) -- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터 -- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문 -- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험 -- 수천 개의 서버 구축 경험 - -## 검토 작업 -다음을 검토하고 간결하게 2-3문장으로 코멘트해주세요: -1. 추천된 서버가 용도와 규모에 적합한지 -2. 스펙이 충분한지 (RAM, CPU, 스토리지) -3. DAU/동시접속자 기준이 적절한지 -4. 대역폭 경고(overage)가 있다면 언급 -5. 더 적합한 스펙이 필요하다면 제안 - -중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.`; - -// Server Expert AI Tools -const serverExpertTools = [ - { - type: 'function' as const, - function: { - name: 'search_trends', - description: '최신 기술 트렌드, 서버 요구사항, 프레임워크 인기도를 검색합니다. 예: "2024 WordPress server requirements", "Next.js hosting best practices"', - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: '검색 쿼리 (영문 권장, 기술 키워드 포함)', - }, - }, - required: ['query'], - }, - }, - }, - { - type: 'function' as const, - function: { - name: 'lookup_framework_docs', - description: '프레임워크/라이브러리 공식 문서에서 서버 요구사항, 배포 가이드, 권장 환경을 조회합니다.', - parameters: { - type: 'object', - properties: { - library: { - type: 'string', - description: '라이브러리/프레임워크 이름 (예: nextjs, laravel, django, wordpress)', - }, - topic: { - type: 'string', - description: '조회할 주제 (예: deployment requirements, production setup, server specs)', - }, - }, - required: ['library', 'topic'], - }, - }, - }, -]; - -// Execute server expert tool -async function executeServerExpertTool( - toolName: string, - args: Record, - env: Env -): Promise { - logger.info('도구 실행', { toolName, args }); - - switch (toolName) { - case 'search_trends': { - const result = await executeSearchWeb({ query: args.query as string }, env); - return result; - } - case 'lookup_framework_docs': { - const result = await executeLookupDocs({ - library: args.library as string, - query: args.topic as string, - }, env); - return result; - } - default: - return `알 수 없는 도구: ${toolName}`; - } -} - -/** - * 사용자 메시지에서 리전 선호도 추출 - * @param message 사용자 메시지 - * @returns 감지된 리전 코드 배열 (undefined if none) - */ -function extractRegionPreference(message: string): string[] | undefined { - const lower = message.toLowerCase(); - const regions: string[] = []; - - // 한국/서울 - if (/한국|서울|seoul|korea|kr\b/.test(lower)) { - regions.push('seoul'); - } - // 일본/도쿄 - if (/일본|도쿄|tokyo|japan|jp\b/.test(lower)) { - regions.push('tokyo'); - } - // 오사카 - if (/오사카|osaka/.test(lower)) { - regions.push('osaka'); - } - // 싱가포르 - if (/싱가포르|singapore|sg\b/.test(lower)) { - regions.push('singapore'); - } - - return regions.length > 0 ? regions : undefined; -} - -/** - * 사용자 메시지에서 기술 스택 추출 - * @param messages 사용자 메시지 (전체 대화 내용) - * @returns 감지된 tech stack 배열 - */ -function extractTechStack(messages: string): string[] { - const lower = messages.toLowerCase(); - const stack: string[] = []; - - // 데이터베이스 - if (/postgresql|postgres|postgis/.test(lower)) stack.push('postgresql'); - if (/mysql|mariadb/.test(lower)) stack.push('mysql'); - if (/mongodb|mongo/.test(lower)) stack.push('mongodb'); - - // 캐시/메시징 - if (/redis/.test(lower)) stack.push('redis'); - if (/memcached/.test(lower)) stack.push('memcached'); - if (/kafka|rabbitmq/.test(lower)) stack.push('messaging'); - - // 런타임 - if (/node\.?js|nodejs|express/.test(lower)) stack.push('nodejs'); - if (/python|django|flask|fastapi/.test(lower)) stack.push('python'); - if (/java|spring/.test(lower)) stack.push('java'); - if (/golang|go\s/.test(lower)) stack.push('go'); - - // 플랫폼 - if (/wordpress/.test(lower)) stack.push('wordpress'); - if (/laravel|php/.test(lower)) stack.push('php'); - - // 서비스 유형 - if (/saas|b2b|enterprise/.test(lower)) stack.push('saas'); - if (/ecommerce|쇼핑몰|이커머스/.test(lower)) stack.push('ecommerce'); - if (/게임|game|minecraft|팰월드|palworld/.test(lower)) stack.push('game'); - if (/streaming|스트리밍|video/.test(lower)) stack.push('streaming'); - - return stack; -} - -// Tech stack inference from use case -function inferTechStack(useCase: string): string[] { - const lower = useCase.toLowerCase(); - - // 고성능 데이터베이스 감지 - if (/postgresql|postgres|postgis/.test(lower)) { - return ['postgresql', 'nodejs']; - } - if (/redis|memcached|cache/.test(lower)) { - return ['redis', 'nodejs']; - } - if (/mongodb|mongo/.test(lower)) { - return ['mongodb', 'nodejs']; - } - - // SaaS / B2B 감지 - 일반적으로 고성능 필요 - if (/saas|b2b|enterprise|엔터프라이즈/.test(lower)) { - return ['nodejs', 'postgresql', 'redis']; - } - - // 실시간 서비스 - if (/realtime|real-time|실시간|websocket|socket\.io/.test(lower)) { - return ['nodejs', 'redis']; - } - - // 기존 규칙들... - if (/블로그|blog|wordpress/.test(lower)) return ['wordpress']; - if (/쇼핑몰|이커머스|ecommerce|shop|store/.test(lower)) return ['ecommerce']; - if (/커뮤니티|게시판|forum|community/.test(lower)) return ['php', 'mysql']; - if (/api|백엔드|backend/.test(lower)) return ['nodejs', 'express']; - if (/게임|game|minecraft|마인크래프트|팰월드|palworld/.test(lower)) return ['game']; - - return ['web']; // Default -} - -// Expected users inference from scale -// Returns concurrent users (not DAU) -function inferExpectedUsers(scale: string, techStack?: string[]): number { - // 고성능 기술 스택이면 기본 사용자 수 증가 - const isHighPerf = techStack?.some(t => - ['postgresql', 'redis', 'mongodb', 'elasticsearch', 'kafka'].includes(t.toLowerCase()) - ); - - // SaaS/Enterprise면 더 높은 기본값 - const isSaaS = techStack?.some(t => - ['saas', 'enterprise', 'b2b'].includes(t.toLowerCase()) - ) || scale === 'saas' || scale === 'enterprise'; - - if (isSaaS) { - return scale === 'business' ? 500 : 200; - } - - if (isHighPerf) { - return scale === 'business' ? 300 : 100; - } - - // 기존 기본값 - // DAU → 동시접속자 변환 (5-10% 비율 적용) - if (scale === 'personal') return 10; // DAU 100명 → 동접 10명 - if (scale === 'business') return 50; // DAU 500명 → 동접 50명 - return 10; // Default to personal -} - - -/** - * Server Expert AI 호출 (Function Calling 지원) - * - * @param session - ServerSession - * @param userMessage - 사용자 메시지 - * @param env - Environment - * @param recommendationData - 추천 결과 (검토 모드용) - * @returns AI 응답 및 수집된 정보 - */ -async function callServerExpertAI( - session: ServerSession, - userMessage: string, - env: Env, - recommendationData?: RecommendResponse -): Promise<{ action: 'question' | 'recommend'; message: string; collectedInfo: ServerSession['collected_info'] }> { - if (!env.OPENAI_API_KEY) { - throw new Error('OPENAI_API_KEY not configured'); - } - - const { getOpenAIUrl } = await import('../utils/api-urls'); - - // Build conversation history - const conversationHistory = session.messages.map(m => ({ - role: m.role === 'user' ? 'user' as const : 'assistant' as const, - content: m.content, - })); - - // 검토 모드: 추천 결과가 있을 때 - const isReviewMode = !!recommendationData; - - const systemPrompt = isReviewMode - ? `${SERVER_REVIEW_PROMPT} - -## 검토 대상 추천 결과 -${JSON.stringify(recommendationData?.recommendations, null, 2)} - -## 사용자 요구사항 -- 용도: ${session.collected_info.useCase || '웹 서비스'} -- 규모: ${session.collected_info.scale === 'business' ? '사업용' : '개인용'} -${session.collected_info.expectedDau ? `- 일일 방문자(DAU): ${session.collected_info.expectedDau}명` : ''} -${session.collected_info.expectedConcurrent ? `- 동시접속자: ${session.collected_info.expectedConcurrent}명` : ''} -${session.collected_info.budgetLimit ? `- 예산: ${session.collected_info.budgetLimit}원` : ''} - -## 사용자 수 관련 참고사항 -- DAU(일일 활성 사용자)와 동시접속자는 다른 개념입니다 -- 일반적으로 동시접속자는 DAU의 5-10% 수준입니다 -- 서버 스펙은 동시접속자 기준으로 계산됩니다 - -## 응답 형식 (반드시 JSON만 반환) -{ - "action": "recommend", - "message": "검토 코멘트 (자연스럽고 친근한 어조, 2-3문장)", - "collectedInfo": ${JSON.stringify(session.collected_info)} -} - -중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.` - : `${SERVER_EXPERT_PROMPT} - -## 현재 수집된 정보 -${JSON.stringify(session.collected_info, null, 2)} - -## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지) -{ - "action": "question" | "recommend", - "message": "사용자에게 보여줄 메시지 (도구에서 얻은 정보를 자연스럽게 포함)", - "collectedInfo": { - "useCase": "용도 (없으면 '웹서비스')", - "scale": "personal 또는 business (없으면 'personal')", - "expectedDau": "일일 방문자 수 (사용자가 명시한 경우)", - "expectedConcurrent": "동시접속자 수 (사용자가 명시하거나 DAU에서 계산)" - } -} - -중요: 정보가 부족해도 기본값으로 action="recommend" 하세요. 30년 경험이면 충분합니다.`; - - try { - // Messages array that we'll build up with tool results - const messages: Array<{ role: string; content: string | null; tool_calls?: OpenAIToolCall[]; tool_call_id?: string; name?: string }> = [ - { role: 'system', content: systemPrompt }, - ...conversationHistory, - { role: 'user', content: userMessage }, - ]; - - const MAX_TOOL_CALLS = 3; - let toolCallCount = 0; - - // Loop to handle tool calls - while (toolCallCount < MAX_TOOL_CALLS) { - // 검토 모드에서는 도구 없이 JSON 응답만 요청 - const requestBody = isReviewMode - ? { - model: 'gpt-4o-mini', - messages, - response_format: { type: 'json_object' }, - max_tokens: 500, - temperature: 0.5, - } - : { - model: 'gpt-4o-mini', - messages, - tools: serverExpertTools, - tool_choice: 'auto', - response_format: { type: 'json_object' }, - max_tokens: 800, - temperature: 0.7, - }; - - const response = await fetch(getOpenAIUrl(env), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${response.status} - ${error}`); - } - - const data = await response.json() as OpenAIAPIResponse; - const assistantMessage = data.choices[0].message; - - // Check if AI wants to call tools - if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { - logger.info('도구 호출 요청', { - tools: assistantMessage.tool_calls.map(tc => tc.function.name), - }); - - // Add assistant message with tool calls - messages.push({ - role: 'assistant', - content: assistantMessage.content, - tool_calls: assistantMessage.tool_calls, - }); - - // Execute tools in parallel for better performance - const toolResults = await Promise.all( - assistantMessage.tool_calls.map(async (toolCall) => { - const args = JSON.parse(toolCall.function.arguments); - const result = await executeServerExpertTool(toolCall.function.name, args, env); - return { - role: 'tool' as const, - tool_call_id: toolCall.id, - name: toolCall.function.name, - content: result, - }; - }) - ); - - messages.push(...toolResults); - toolCallCount += toolResults.length; - - // Continue loop to get AI's response with tool results - continue; - } - - // No tool calls - parse the final response - const aiResponse = assistantMessage.content || ''; - logger.info('AI 응답', { response: aiResponse.slice(0, 200), toolCallCount }); - - // JSON 파싱 (마크다운 코드 블록 제거) - const jsonMatch = aiResponse.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) || - aiResponse.match(/(\{[\s\S]*\})/); - - if (!jsonMatch) { - logger.error('JSON 파싱 실패', new Error('No JSON found'), { response: aiResponse }); - throw new Error('AI 응답 형식 오류'); - } - - const parsed = JSON.parse(jsonMatch[1]); - - // Validate response structure - if (!parsed.action || !parsed.message) { - throw new Error('Invalid AI response structure'); - } - - // AI 응답에서 리전 정보가 없으면 사용자 메시지에서 추출 시도 - const finalCollectedInfo = parsed.collectedInfo || session.collected_info; - - if (!finalCollectedInfo.regionPreference) { - // 전체 대화 히스토리에서 리전 감지 - const allMessages = [ - ...session.messages.map(m => m.content), - userMessage, - ].join(' '); - - const detectedRegions = extractRegionPreference(allMessages); - if (detectedRegions) { - finalCollectedInfo.regionPreference = detectedRegions; - logger.info('사용자 메시지에서 리전 자동 감지', { - regions: detectedRegions, - userId: session.user_id - }); - } - } - - return { - action: parsed.action, - message: parsed.message, - collectedInfo: finalCollectedInfo, - }; - } - - // Max tool calls reached, force a recommendation - logger.warn('최대 도구 호출 횟수 도달', { toolCallCount }); - return { - action: 'recommend', - message: '분석이 완료되었습니다. 최적의 서버를 추천해 드리겠습니다.', - collectedInfo: session.collected_info, - }; - } catch (error) { - logger.error('Server Expert AI 호출 실패', error as Error); - throw error; - } -} - -/** - * 서버 상담 처리 (메인 함수) - * - * @param db - D1 Database - * @param userId - Telegram User ID - * @param userMessage - 사용자 메시지 - * @param env - Environment - * @param options - Optional settings - * @returns AI 응답 메시지 - */ -export async function processServerConsultation( - db: D1Database, - userId: string, - userMessage: string, - env: Env, - options?: { sendIntermediateMessage?: (msg: string) => Promise } -): Promise { - logger.info('서버 상담 시작', { userId, message: userMessage.substring(0, 100) }); - - try { - // 1. Check for existing session - let session = await sessionManager.get(db, userId); - - // 2. Create new session if none exists - if (!session) { - session = sessionManager.create(userId, 'gathering'); - } - - // ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리 - if (session.status === 'ordering') { - // "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임 - const orderConfirmKey = `server_order_confirm:${session.user_id}`; - await env.SESSION_KV?.delete(orderConfirmKey); - await sessionManager.delete(db, session.user_id); - - logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.user_id }); - return '__PASSTHROUGH__'; // 일반 대화로 전환 - } - - // 취소 키워드 처리 (모든 상태에서 작동) - // "취소", "다시", "처음", "리셋", "초기화" 등 - if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) || - /취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) { - await sessionManager.delete(db, session.user_id); - logger.info('사용자 요청으로 상담 취소', { - userId: session.user_id, - previousStatus: session.status, - trigger: userMessage.slice(0, 20) - }); - return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.'; - } - - // "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋) - if (/서버\s*추천/.test(userMessage)) { - await sessionManager.delete(db, session.user_id); - logger.info('서버 추천 키워드로 세션 리셋', { - userId: session.user_id, - previousStatus: session.status - }); - // 새 세션 생성하고 시작 메시지 반환 - const newSession = sessionManager.create(session.user_id, 'gathering'); - await sessionManager.save(db, newSession); - return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!'; - } - - // 선택 단계 처리 - logger.info('[SESSION DEBUG] 선택 단계 체크', { - userId: session.user_id, - status: session.status, - hasLastRecommendation: !!session.last_recommendation, - recommendationCount: session.last_recommendation?.recommendations?.length || 0, - willProcessSelection: session.status === SERVER_CONSULTATION_STATUS.SELECTING && !!session.last_recommendation - }); - - if (session.status === SERVER_CONSULTATION_STATUS.SELECTING && session.last_recommendation) { - // 상담과 무관한 키워드 감지 (selecting 상태에서만) - // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 - const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; - if (unrelatedPatterns.test(userMessage)) { - await sessionManager.delete(db, session.user_id); - logger.info('무관한 요청으로 세션 자동 종료', { - userId: session.user_id, - message: userMessage.slice(0, 30) - }); - // 'PASSTHROUGH' 반환하여 상위에서 일반 처리로 전환 - return '__PASSTHROUGH__'; - } - - const selectionMatch = userMessage.match(/^(\d+)\s*(?:번|번째)?$|^(첫|두|세)\s*번째$/); - - if (selectionMatch) { - let selectedIndex = -1; - - // 숫자 추출 - if (selectionMatch[1]) { - selectedIndex = parseInt(selectionMatch[1], 10) - 1; - } else if (userMessage.includes('첫')) { - selectedIndex = 0; - } else if (userMessage.includes('두')) { - selectedIndex = 1; - } else if (userMessage.includes('세')) { - selectedIndex = 2; - } - - // 유효성 검증 - if (selectedIndex >= 0 && selectedIndex < session.last_recommendation.recommendations.length) { - const selected = session.last_recommendation.recommendations[selectedIndex]; - - // Mark session as ordering - session.status = 'ordering'; - await sessionManager.save(db, session); - - // 주문 확인 세션 저장 (텍스트 기반 확인) - const orderConfirmKey = `server_order_confirm:${session.user_id}`; - const orderConfirmData = JSON.stringify({ - userId: session.user_id, - index: selectedIndex, - plan: selected.plan_name, - pricingId: selected.pricing_id, - region: selected.region.code, - label: `${selected.plan_name.toLowerCase().replace(/\s+/g, '-')}-server`, - }); - logger.info('주문 확인 세션 저장', { orderConfirmKey, userId: session.user_id }); - await env.SESSION_KV.put(orderConfirmKey, orderConfirmData, { expirationTtl: 300 }); - logger.info('주문 확인 세션 저장 완료', { orderConfirmKey }); - - // 트래픽 정보 포맷팅 - let trafficInfo = ''; - if (selected.price.estimated_monthly_tb !== undefined) { - const bandwidthInfo: BandwidthInfo = { - included_transfer_tb: selected.price.bandwidth_tb, - overage_cost_per_gb: 0, - overage_cost_per_tb: 0, - estimated_monthly_tb: selected.price.estimated_monthly_tb, - estimated_overage_tb: selected.price.overage_tb || 0, - estimated_overage_cost: selected.price.overage_cost_krw || 0, - total_estimated_cost: selected.price.monthly_krw + (selected.price.overage_cost_krw || 0), - currency: 'KRW', - gross_monthly_tb: selected.price.gross_monthly_tb, - cdn_cache_hit_rate: selected.price.cdn_cache_hit_rate, - }; - trafficInfo = `• ${formatTrafficInfo(bandwidthInfo)}\n`; - } - - // 가격 표시 (항상 KRW로 표시) - const priceDisplay = `₩${selected.price.monthly_krw.toLocaleString()}`; - - return `🖥️ ${selected.plan_name} 신청 확인\n\n` + - `• 제공사: ${selected.provider}\n` + - `• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB RAM / ${selected.specs.storage_gb}GB SSD\n` + - `• 리전: ${selected.region.name} (${selected.region.code})\n` + - `• 가격: ${priceDisplay}/월\n` + - `• 대역폭: ${selected.price.bandwidth_tb}TB 포함\n` + - trafficInfo + - `\n⚠️ 정말 신청하시려면 '신청'이라고 입력하세요.\n` + - `(5분 내 응답 없으면 자동 취소됩니다)`; - } else { - return `번호를 다시 확인해주세요. 1번부터 ${session.last_recommendation.recommendations.length}번 중에서 선택해주세요.`; - } - } - - // 선택하지 않고 다른 질문을 한 경우 - return '서버 번호를 선택해주세요. (예: 1번)\n또는 "취소"라고 말씀하시면 처음부터 다시 시작합니다.'; - } - - // Add user message to history - session.messages.push({ role: 'user', content: userMessage }); - - // Call Server Expert AI - const aiResult = await callServerExpertAI(session, userMessage, env); - - // Update collected info - session.collected_info = { ...session.collected_info, ...aiResult.collectedInfo }; - - // Add AI response to history - session.messages.push({ role: 'assistant', content: aiResult.message }); - - if (aiResult.action === 'recommend') { - // Send intermediate message to user - if (options?.sendIntermediateMessage) { - await options?.sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.'); - } - - // Mark session as recommending - session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING; - await sessionManager.save(db, session); - - // 1. Call recommendation API (추천 먼저 받기) - logger.info('추천 API 호출', { collectedInfo: session.collected_info }); - - const { executeServerAction, getRecommendationData } = await import('../tools/server-tool'); - - // 전체 메시지 내용 (tech stack 추출 및 리전 추출에 재사용) - const allMessages = session.messages.map(m => m.content).join(' '); - - // Tech Stack: useCase에서 추론 + 전체 메시지에서 추출한 것 병합 - let techStack = session.collected_info.useCase - ? inferTechStack(session.collected_info.useCase) - : ['web']; - - // 전체 메시지에서 추가 tech stack 추출 - const extractedTech = extractTechStack(allMessages); - if (extractedTech.length > 0) { - // 추출된 tech를 기존 stack에 병합 (중복 제거) - techStack = [...new Set([...techStack, ...extractedTech])]; - // 'web' 제거 (더 구체적인 stack이 있으면) - if (techStack.length > 1 && techStack.includes('web')) { - techStack = techStack.filter(t => t !== 'web'); - } - logger.info('메시지에서 tech stack 추출', { - extracted: extractedTech, - merged: techStack, - userId: session.user_id - }); - } - - // 동시접속자 우선 사용, 없으면 scale 기반 추론 - let expectedUsers = 10; // Default - const concurrent = Number(session.collected_info.expectedConcurrent) || 0; - const dau = Number(session.collected_info.expectedDau) || 0; - - if (concurrent > 0) { - expectedUsers = concurrent; - } else if (dau > 0) { - // DAU가 있으면 10% 비율로 동시접속자 계산 - expectedUsers = Math.ceil(dau * 0.1); - } else if (session.collected_info.scale) { - expectedUsers = inferExpectedUsers(session.collected_info.scale, techStack); - } - - // 리전 선호도 최종 확인 (세션에 없으면 메시지에서 재추출) - let finalRegionPreference = session.collected_info.regionPreference; - if (!finalRegionPreference) { - finalRegionPreference = extractRegionPreference(allMessages); - if (finalRegionPreference) { - logger.info('추천 직전 리전 재감지', { - regions: finalRegionPreference, - userId: session.user_id - }); - } - } - - const recommendationData = await getRecommendationData( - { - tech_stack: techStack, - expected_users: expectedUsers, - use_case: session.collected_info.useCase || '웹 서비스', - region_preference: finalRegionPreference, - budget_limit: session.collected_info.budgetLimit, - lang: LANGUAGE_CODE.KOREAN, - }, - env - ); - - // 추천 결과를 세션에 저장 - if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) { - session.last_recommendation = { - recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ - pricing_id: rec.server.id, // cloud-instances-db.anvil_pricing.id - plan_name: rec.server.instance_name, - provider: rec.server.provider_name, - specs: { - vcpu: rec.server.vcpu, - ram_gb: rec.server.memory_gb, - storage_gb: rec.server.storage_gb - }, - region: { - code: rec.server.region_code, - name: rec.server.region_name - }, - price: { - monthly_krw: Math.round(rec.server.monthly_price), - bandwidth_tb: rec.server.transfer_tb, - estimated_monthly_tb: rec.bandwidth_info?.estimated_monthly_tb, - gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb, - cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate, - overage_tb: rec.bandwidth_info?.estimated_overage_tb, - overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost, - currency: rec.server.currency, - }, - score: rec.score, - max_users: rec.estimated_capacity?.max_concurrent_users || 0 - })), - created_at: Date.now() - }; - - // 2. AI에게 추천 결과 전달하여 검토 요청 - logger.info('AI 검토 요청', { recommendationCount: recommendationData.recommendations.length }); - const reviewResult = await callServerExpertAI(session, userMessage, env, recommendationData); - - // 3. 포맷팅된 추천 결과 생성 - const formattedRecommendation = await executeServerAction( - 'recommend', - { - tech_stack: techStack, - expected_users: expectedUsers, - use_case: session.collected_info.useCase || '웹 서비스', - region_preference: session.collected_info.regionPreference, - budget_limit: session.collected_info.budgetLimit, - lang: LANGUAGE_CODE.KOREAN, - }, - env, - session.user_id - ); - - // Mark session as selecting (사용자 선택 대기) - session.status = SERVER_CONSULTATION_STATUS.SELECTING; - await sessionManager.save(db, session); - - // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에) - // __DIRECT__ 마커가 앞에 와야 제대로 처리됨 - return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; - } else { - // 추천 결과 없음 - 세션 삭제 - session.status = SERVER_CONSULTATION_STATUS.COMPLETED; - await sessionManager.delete(db, session.user_id); - - return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; - } - } else { - // Continue gathering information - session.status = SERVER_CONSULTATION_STATUS.GATHERING; - await sessionManager.save(db, session); - - return aiResult.message; - } - } catch (error) { - logger.error('상담 처리 실패', error as Error, { userId }); - - // Clean up session on error (if exists) - try { - await sessionManager.delete(db, userId); - } catch (deleteError) { - logger.error('세션 삭제 실패 (무시)', deleteError as Error, { userId }); - } - - return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.'; - } -} diff --git a/src/constants/agent-config.ts b/src/constants/agent-config.ts index c939283..a770895 100644 --- a/src/constants/agent-config.ts +++ b/src/constants/agent-config.ts @@ -17,6 +17,7 @@ export const SESSION_TTL = { troubleshoot: 60 * 60 * 1000, // 1 hour domain: 60 * 60 * 1000, // 1 hour deposit: 30 * 60 * 1000, // 30 minutes + ddos: 60 * 60 * 1000, // 1 hour } as const; // Maximum messages to keep in session history @@ -25,6 +26,7 @@ export const MAX_SESSION_MESSAGES = { troubleshoot: 20, domain: 20, deposit: 10, + ddos: 20, } as const; // OpenAI API configuration @@ -39,6 +41,7 @@ export const AI_CONFIG = { troubleshoot: 1500, domain: 800, deposit: 500, + ddos: 1000, }, temperature: { server: 0.7, @@ -46,6 +49,7 @@ export const AI_CONFIG = { troubleshoot: 0.5, domain: 0.7, deposit: 0.7, + ddos: 0.5, // 정확한 보안 조언 }, } as const; @@ -55,6 +59,7 @@ export const SESSION_TABLES = { troubleshoot: 'troubleshoot_sessions', domain: 'domain_sessions', deposit: 'deposit_sessions', + ddos: 'ddos_sessions', } as const; // Agent type definitions for type safety diff --git a/src/services/ddos-defense-service.ts b/src/services/ddos-defense-service.ts new file mode 100644 index 0000000..6d887a1 --- /dev/null +++ b/src/services/ddos-defense-service.ts @@ -0,0 +1,329 @@ +/** + * DDoS Defense Service - Stub Implementation + * + * This service provides the interface for actual DDoS defense operations. + * Currently implemented as stubs - will be connected to real systems: + * - Cloudflare API (WAF, Rate Limiting, Under Attack Mode) + * - Incus firewall rules + * - Custom rate limiting + * + * TODO: Implement actual integrations + */ + +import { createLogger } from '../utils/logger'; +import type { DdosAnalysisResult, DdosMitigationResult, DdosStatusResult } from '../types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type Env = import('../types').Env; + +const logger = createLogger('ddos-defense-service'); + +// ============================================ +// Traffic Analysis (STUB) +// ============================================ + +/** + * Analyze current traffic patterns to detect DDoS attacks + * + * TODO: Connect to Cloudflare Analytics API or custom monitoring + */ +export async function analyzeTraffic( + target: string, + _env: Env +): Promise { + logger.info('트래픽 분석 시작 (STUB)', { target }); + + // STUB: Return mock analysis + return { + attack_type: 'unknown', + severity: 'medium', + estimated_volume: 'N/A (stub)', + source_analysis: '실제 구현 시 트래픽 데이터 분석 예정', + recommendations: [ + 'Cloudflare Under Attack Mode 활성화 고려', + 'Rate Limiting 규칙 검토', + '의심 IP 대역 차단 검토', + ], + }; +} + +/** + * Detect attack type from symptoms description + * + * TODO: Use ML or pattern matching on actual traffic data + */ +export function detectAttackType(symptoms: string): 'volumetric' | 'protocol' | 'application' | 'unknown' { + const symptomsLower = symptoms.toLowerCase(); + + // Simple keyword matching (STUB logic) + if (symptomsLower.includes('bandwidth') || symptomsLower.includes('대역폭') || symptomsLower.includes('트래픽')) { + return 'volumetric'; + } + if (symptomsLower.includes('syn') || symptomsLower.includes('tcp') || symptomsLower.includes('udp')) { + return 'protocol'; + } + if (symptomsLower.includes('http') || symptomsLower.includes('api') || symptomsLower.includes('slow')) { + return 'application'; + } + + return 'unknown'; +} + +// ============================================ +// Cloudflare Integration (STUB) +// ============================================ + +/** + * Enable Cloudflare Under Attack Mode + * + * TODO: Implement using Cloudflare API + * POST /zones/{zone_id}/settings/security_level + */ +export async function enableUnderAttackMode( + zoneId: string, + _env: Env +): Promise { + logger.info('Under Attack Mode 활성화 (STUB)', { zoneId }); + + // STUB + return { + success: true, + actions_taken: ['[STUB] Cloudflare Under Attack Mode 활성화'], + status: 'applied', + message: '실제 구현 시 Cloudflare API 호출 예정', + }; +} + +/** + * Add Cloudflare WAF rule to block suspicious traffic + * + * TODO: Implement using Cloudflare Firewall Rules API + */ +export async function addWafRule( + zoneId: string, + rule: { + name: string; + expression: string; + action: 'block' | 'challenge' | 'js_challenge'; + }, + _env: Env +): Promise { + logger.info('WAF 규칙 추가 (STUB)', { zoneId, ruleName: rule.name }); + + // STUB + return { + success: true, + actions_taken: [`[STUB] WAF 규칙 추가: ${rule.name}`], + status: 'applied', + message: `표현식: ${rule.expression}, 액션: ${rule.action}`, + }; +} + +/** + * Configure Cloudflare Rate Limiting + * + * TODO: Implement using Cloudflare Rate Limiting API + */ +export async function configureRateLimiting( + zoneId: string, + config: { + threshold: number; + period: number; + action: 'block' | 'challenge'; + urlPattern: string; + }, + _env: Env +): Promise { + logger.info('Rate Limiting 설정 (STUB)', { zoneId, config }); + + // STUB + return { + success: true, + actions_taken: [`[STUB] Rate Limiting 설정: ${config.threshold} req/${config.period}s`], + status: 'applied', + message: `URL 패턴: ${config.urlPattern}`, + }; +} + +// ============================================ +// Incus/Server Firewall (STUB) +// ============================================ + +/** + * Block IP addresses at server firewall level (Incus) + * + * TODO: Implement using Incus MCP or direct API + */ +export async function blockIpsAtFirewall( + instanceName: string, + ips: string[], + _env: Env +): Promise { + logger.info('방화벽 IP 차단 (STUB)', { instanceName, ipCount: ips.length }); + + // STUB + return { + success: true, + actions_taken: [`[STUB] ${ips.length}개 IP 차단 규칙 추가`], + status: 'applied', + message: `대상 인스턴스: ${instanceName}`, + }; +} + +/** + * Configure iptables rate limiting + * + * TODO: Implement using Incus exec + */ +export async function configureIptablesRateLimit( + instanceName: string, + config: { + limit: string; // e.g., "25/minute" + burst: number; + port?: number; + }, + _env: Env +): Promise { + logger.info('iptables Rate Limit 설정 (STUB)', { instanceName, config }); + + // STUB + return { + success: true, + actions_taken: [`[STUB] iptables rate limit: ${config.limit}, burst: ${config.burst}`], + status: 'applied', + message: `대상 인스턴스: ${instanceName}`, + }; +} + +// ============================================ +// Status & Monitoring (STUB) +// ============================================ + +/** + * Get current DDoS protection status + * + * TODO: Aggregate data from Cloudflare, server metrics, etc. + */ +export async function getProtectionStatus( + target: string, + _env: Env +): Promise { + logger.info('방어 상태 조회 (STUB)', { target }); + + // STUB + return { + is_under_attack: false, + current_traffic: 'N/A (stub)', + blocked_requests: 0, + active_rules: ['[STUB] 기본 WAF 규칙'], + protection_level: 'medium', + }; +} + +/** + * Get attack history and statistics + * + * TODO: Query from D1 or external analytics + */ +export async function getAttackHistory( + target: string, + days: number = 7, + _env: Env +): Promise<{ + attacks: Array<{ + timestamp: string; + type: string; + duration_minutes: number; + peak_traffic: string; + mitigated: boolean; + }>; +}> { + logger.info('공격 히스토리 조회 (STUB)', { target, days }); + + // STUB + return { + attacks: [], + }; +} + +// ============================================ +// Composite Actions (STUB) +// ============================================ + +/** + * Apply recommended mitigation based on attack analysis + * + * This is the main entry point for automated defense + */ +export async function applyRecommendedMitigation( + analysis: DdosAnalysisResult, + target: string, + provider: string, + _env: Env +): Promise { + logger.info('권장 방어 조치 적용 (STUB)', { + attackType: analysis.attack_type, + severity: analysis.severity, + target, + provider + }); + + const actions: string[] = []; + + // Determine actions based on severity and provider + if (analysis.severity === 'critical' || analysis.severity === 'high') { + if (provider === 'cloudflare') { + actions.push('[STUB] Cloudflare Under Attack Mode 활성화'); + actions.push('[STUB] 강화된 Rate Limiting 적용'); + } + actions.push('[STUB] 의심 IP 대역 차단'); + } + + if (analysis.attack_type === 'volumetric') { + actions.push('[STUB] 대역폭 기반 필터링 활성화'); + } else if (analysis.attack_type === 'application') { + actions.push('[STUB] Layer 7 WAF 규칙 강화'); + } + + // STUB + return { + success: true, + actions_taken: actions.length > 0 ? actions : ['[STUB] 기본 방어 조치 유지'], + status: 'applied', + message: `심각도: ${analysis.severity}, 공격 유형: ${analysis.attack_type}`, + }; +} + +// ============================================ +// Emergency Response (STUB) +// ============================================ + +/** + * Activate emergency defense mode + * + * Used for critical situations requiring immediate action + */ +export async function activateEmergencyDefense( + target: string, + _env: Env +): Promise { + logger.warn('긴급 방어 모드 활성화 (STUB)', { target }); + + // STUB: In real implementation, this would: + // 1. Enable Cloudflare Under Attack Mode + // 2. Apply strictest rate limiting + // 3. Block all non-essential traffic + // 4. Alert administrators + + return { + success: true, + actions_taken: [ + '[STUB] 긴급 방어 모드 활성화', + '[STUB] 모든 트래픽 JS Challenge 적용', + '[STUB] Rate Limiting 최대 강화', + '[STUB] 관리자 알림 발송', + ], + status: 'emergency', + message: '긴급 방어 모드가 활성화되었습니다. 실제 구현 시 즉각적인 조치가 적용됩니다.', + }; +} diff --git a/src/tools/ddos-tool.ts b/src/tools/ddos-tool.ts new file mode 100644 index 0000000..c06984b --- /dev/null +++ b/src/tools/ddos-tool.ts @@ -0,0 +1,73 @@ +import type { Env, DdosSession } from '../types'; +import { createLogger } from '../utils/logger'; +import { SessionManager } from '../utils/session-manager'; +import { getSessionConfig } from '../constants/agent-config'; + +const logger = createLogger('ddos-tool'); + +// Module-level singleton (consistent with other agents) +const sessionManager = new SessionManager(getSessionConfig('ddos')); + +export const manageDdosTool = { + type: 'function', + function: { + name: 'manage_ddos', + description: 'DDoS 공격 방어 도우미. 사이트가 공격받고 있거나, 트래픽 폭주, 서비스 마비, 접속 불가 등의 상황에서 사용합니다. "DDoS", "공격", "트래픽 폭주", "서비스 마비", "봇 공격" 등을 언급하면 이 도구를 사용하세요.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['start', 'cancel'], + description: 'start=DDoS 방어 상담 시작, cancel=세션 취소', + }, + }, + required: ['action'], + }, + }, +}; + +export async function executeManageDdos( + args: { action: 'start' | 'cancel' }, + env?: Env, + telegramUserId?: string +): Promise { + const { action } = args; + + logger.info('DDoS 도구 호출', { action, userId: telegramUserId }); + + if (!env?.DB) { + logger.error('DDoS 도구: 데이터베이스 연결 없음'); + return '🚫 시스템 설정 오류입니다. 관리자에게 문의하세요.'; + } + if (!telegramUserId) { + logger.error('DDoS 도구: 사용자 ID 없음'); + return '🚫 사용자 인증이 필요합니다. 다시 시도해주세요.'; + } + + if (action === 'cancel') { + await sessionManager.delete(env.DB, telegramUserId); + return '✅ DDoS 방어 세션이 취소되었습니다.'; + } + + // action === 'start' + const existingSession = await sessionManager.get(env.DB, telegramUserId); + + if (existingSession && existingSession.status !== 'completed') { + return '이미 진행 중인 DDoS 방어 세션이 있습니다. 계속 진행해주세요.\n\n현재까지 파악된 정보:\n' + + (existingSession.collected_info.attack_type ? `• 공격 유형: ${existingSession.collected_info.attack_type}\n` : '') + + (existingSession.collected_info.target ? `• 대상: ${existingSession.collected_info.target}\n` : '') + + (existingSession.collected_info.symptoms ? `• 증상: ${existingSession.collected_info.symptoms}\n` : ''); + } + + // Create new session + const newSession = sessionManager.create(telegramUserId, 'gathering'); + + await sessionManager.save(env.DB, newSession); + + logger.info('DDoS 방어 세션 시작', { userId: telegramUserId }); + + return '__DIRECT__🛡️ 안녕하세요! 사이트에 문제가 생기셨군요.\n\n' + + '걱정 마세요, 차근차근 도와드릴게요.\n\n' + + '어떤 증상이 나타나고 있나요?'; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 9ebd18d..e762e6d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -11,6 +11,7 @@ import { manageDomainTool, suggestDomainsTool, executeManageDomain, executeSugge import { manageDepositTool, executeManageDeposit } from './deposit-tool'; import { manageServerTool, executeManageServer } from './server-tool'; import { manageTroubleshootTool, executeManageTroubleshoot } from './troubleshoot-tool'; +import { manageDdosTool, executeManageDdos } from './ddos-tool'; import { getCurrentTimeTool, calculateTool, executeGetCurrentTime, executeCalculate } from './utility-tools'; import { redditSearchTool, executeRedditSearch } from './reddit-tool'; import type { Env } from '../types'; @@ -88,6 +89,10 @@ const ManageTroubleshootArgsSchema = z.object({ action: z.enum(['start', 'cancel']), }); +const ManageDdosArgsSchema = z.object({ + action: z.enum(['start', 'cancel']), +}); + // All tools array (used by OpenAI API) export const tools = [ weatherTool, @@ -99,6 +104,7 @@ export const tools = [ manageDepositTool, manageServerTool, manageTroubleshootTool, + manageDdosTool, suggestDomainsTool, redditSearchTool, ]; @@ -109,6 +115,7 @@ export const TOOL_CATEGORIES: Record = { deposit: [manageDepositTool.function.name], server: [manageServerTool.function.name], troubleshoot: [manageTroubleshootTool.function.name], + ddos: [manageDdosTool.function.name], weather: [weatherTool.function.name], search: [searchWebTool.function.name, lookupDocsTool.function.name], reddit: [redditSearchTool.function.name], @@ -183,6 +190,7 @@ const toolExecutors: Record< manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'), search_reddit: createValidatedExecutor(RedditSearchArgsSchema, executeRedditSearch, 'reddit'), manage_troubleshoot: createValidatedExecutor(ManageTroubleshootArgsSchema, executeManageTroubleshoot, 'troubleshoot'), + manage_ddos: createValidatedExecutor(ManageDdosArgsSchema, executeManageDdos, 'ddos'), }; // Tool execution dispatcher with validation diff --git a/src/types.ts b/src/types.ts index 919e654..dd4be22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -823,3 +823,68 @@ export interface ArchiveResult { created_summaries: number; errors: string[]; } + +// DDoS Defense Session Status +export type DdosSessionStatus = + | 'gathering' // 정보 수집 중 + | 'analyzing' // 공격 분석 중 + | 'mitigating' // 방어 조치 중 + | 'monitoring' // 모니터링 중 + | 'completed'; // 완료 + +// DDoS Defense Session (D1) +export interface DdosSession { + user_id: string; + status: DdosSessionStatus; + collected_info: { + attack_type?: 'volumetric' | 'protocol' | 'application' | 'unknown'; + target?: string; // IP, domain, or service name + symptoms?: string; // 증상 설명 + traffic_volume?: string; // 예: "10Gbps", "1M requests/sec" + source_ips?: string[]; // 공격 소스 IP (파악 시) + provider?: 'cloudflare' | 'aws' | 'incus' | 'other'; // 인프라 제공자 + current_protection?: string[]; // 현재 방어 수단 + }; + messages: Array<{ role: 'user' | 'assistant'; content: string }>; + created_at: number; + updated_at: number; + expires_at: number; +} + +// DDoS Defense Tool Args +export interface ManageDdosArgs { + action: + | 'start_defense' // 방어 상담 시작 + | 'analyze_attack' // 공격 분석 + | 'apply_mitigation' // 방어 조치 적용 + | 'check_status' // 현재 상태 확인 + | 'get_recommendations' // 방어 권장사항 + | 'end_session'; // 세션 종료 + target?: string; + attack_type?: string; + message?: string; // 사용자 메시지 (상담용) +} + +// DDoS Defense Service Results +export interface DdosAnalysisResult { + attack_type: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + estimated_volume: string; + source_analysis: string; + recommendations: string[]; +} + +export interface DdosMitigationResult { + success: boolean; + actions_taken: string[]; + status: string; + message: string; +} + +export interface DdosStatusResult { + is_under_attack: boolean; + current_traffic: string; + blocked_requests: number; + active_rules: string[]; + protection_level: string; +} diff --git a/src/utils/patterns.ts b/src/utils/patterns.ts index 22b4132..7caebaa 100644 --- a/src/utils/patterns.ts +++ b/src/utils/patterns.ts @@ -18,6 +18,7 @@ export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려| export const WEATHER_PATTERNS = /날씨|기온|비|눈|맑|흐림|더워|추워/i; export const SEARCH_PATTERNS = /검색|찾아|뭐야|뉴스|최신/i; export const REDDIT_PATTERNS = /레딧|reddit|서브레딧|subreddit/i; +export const DDOS_PATTERNS = /ddos|DDoS|공격|트래픽\s*폭주|서비스\s*마비|봇\s*공격|디도스|대역폭\s*공격/i; // ============================================================================ // Memory Category Patterns @@ -104,6 +105,7 @@ export function detectToolCategories(text: string): string[] { if (DEPOSIT_PATTERNS.test(text)) categories.push('deposit'); if (SERVER_PATTERNS.test(text)) categories.push('server'); if (TROUBLESHOOT_PATTERNS.test(text)) categories.push('troubleshoot'); + if (DDOS_PATTERNS.test(text)) categories.push('ddos'); if (WEATHER_PATTERNS.test(text)) categories.push('weather'); if (SEARCH_PATTERNS.test(text)) categories.push('search'); if (REDDIT_PATTERNS.test(text)) categories.push('reddit');