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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-05 18:25:36 +09:00
parent 6e3e8d8abb
commit 7d43db3054
13 changed files with 2961 additions and 905 deletions

View File

@@ -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<string>
// 메시지 저장
export async function saveMessage(db: D1Database, telegramId: string, message: ConversationMessage): Promise<void>
// 최근 메시지 조회
export async function getRecentMessages(db: D1Database, telegramId: string, limit: number): Promise<ConversationMessage[]>
// 키워드 검색
export async function searchRelevantMessages(db: D1Database, telegramId: string, keywords: string[], limit: number): Promise<ConversationMessage[]>
// 스마트 컨텍스트 (최근 20개 + 관련 10개)
export async function getSmartContext(db: D1Database, telegramId: string, currentMessage: string): Promise<ConversationMessage[]>
// 통계 조회
export async function getConversationStats(db: D1Database, telegramId: string): Promise<ConversationStats>
```
### ArchiveService (`src/services/archive-service.ts`)
```typescript
// 전체 아카이브 (Cron)
export async function archiveOldConversations(env: Env): Promise<ArchiveResult>
// 사용자별 아카이브
export async function archiveUserConversations(env: Env, telegramId: string, olderThanDays: number): Promise<number>
// 아카이브 요약 생성
export async function generateArchiveSummary(env: Env, messages: ConversationMessage[]): Promise<string>
```
---
## 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` 테이블 관련 코드

File diff suppressed because it is too large Load Diff

View File

@@ -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<void>) | 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## Task 7: commands.ts - /server 명령어 응답 수정
**Files:**
- Modify: `src/commands.ts:268-274`
**Step 1: "서버 추천" 안내 문구 제거**
```typescript
// 변경 전 (줄 268-274)
if (servers.length === 0) {
return `🖥️ <b>내 서버</b>
보유한 서버가 없습니다.
"서버 추천" 또는 "서버 신청"으로 시작하세요!`;
}
// 변경 후
if (servers.length === 0) {
return `🖥️ <b>내 서버</b>
보유한 서버가 없습니다.`;
}
```
**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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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)

View File

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

View File

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

536
src/agents/ddos-agent.ts Normal file
View File

@@ -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<DdosSession>(getSessionConfig('ddos'));
/**
* DDoS 세션 존재 여부 확인 (라우팅용)
*/
export async function hasDdosSession(db: D1Database, userId: string): Promise<boolean> {
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<string, unknown>,
session: DdosSession,
env: Env
): Promise<string> {
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<string, unknown>;
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<string> {
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 방어 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}

View File

@@ -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<boolean> {
return await sessionManager.has(db, userId);
}
/**
* 만료된 서버 세션 정리 (Cron 또는 수동 실행)
*
* @param db - D1 Database
* @returns 삭제된 세션 개수
*/
export async function cleanupExpiredSessions(db: D1Database): Promise<number> {
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<string, unknown>,
env: Env
): Promise<string> {
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<void> }
): Promise<string> {
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다시 시도하려면 "서버 추천"이라고 말씀해주세요.';
}
}

View File

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

View File

@@ -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<DdosAnalysisResult> {
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<DdosMitigationResult> {
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<DdosMitigationResult> {
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<DdosMitigationResult> {
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<DdosMitigationResult> {
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<DdosMitigationResult> {
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<DdosStatusResult> {
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<DdosMitigationResult> {
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<DdosMitigationResult> {
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: '긴급 방어 모드가 활성화되었습니다. 실제 구현 시 즉각적인 조치가 적용됩니다.',
};
}

73
src/tools/ddos-tool.ts Normal file
View File

@@ -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<DdosSession>(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<string> {
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' +
'어떤 증상이 나타나고 있나요?';
}

View File

@@ -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<string, string[]> = {
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

View File

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

View File

@@ -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');