Initial implementation of Telegram AI customer support bot

Cloudflare Workers + Hono + D1 + KV + R2 stack with 4 specialized AI agents
(onboarding, troubleshoot, asset, billing), OpenAI function calling with
7 tool definitions, human escalation, pending action approval workflow,
feedback collection, audit logging, i18n (ko/en), and Workers AI fallback.

43 source files, 45 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-11 13:21:38 +09:00
commit 1d6b64c9e4
58 changed files with 12857 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars

93
CLAUDE.md Normal file
View File

@@ -0,0 +1,93 @@
# CLAUDE.md - telegram-ai-support
Telegram AI 고객 지원 봇. Cloudflare Workers + Hono + D1 + KV + R2 + Workers AI.
## Commands
```bash
npm run typecheck # TypeScript 타입 체크
npm test # Vitest 테스트 실행
npx wrangler deploy # Workers 배포
npx wrangler d1 execute telegram-ai-support-db --file=schema.sql # D1 스키마 적용
```
## Architecture
```
src/
├── index.ts # Hono 앱 + cron scheduled handler
├── types.ts # 모든 타입 정의 (Env, Telegram, OpenAI, DB 모델)
├── telegram.ts # Telegram Bot API 래퍼
├── security.ts # webhook 인증, rate limit, admin 체크
├── agents/ # AI 에이전트 시스템
│ ├── base-agent.ts # 추상 기반 (세션, AI 호출 루프, 마커 처리)
│ ├── agent-registry.ts # 우선순위 기반 에이전트 라우팅
│ ├── onboarding-agent.ts # 신규 고객 안내 (multi-round)
│ ├── troubleshoot-agent.ts # 장애/문제 해결 (multi-round, 에스컬레이션)
│ ├── asset-agent.ts # 자산 조회 (single-shot)
│ └── billing-agent.ts # 결제/충전 (single-shot, optimistic lock)
├── tools/ # OpenAI Function Calling 도구
│ ├── index.ts # Zod 검증 + 도구 레지스트리 + 동적 선택
│ ├── domain-tool.ts # 도메인 WHOIS/등록/관리
│ ├── wallet-tool.ts # 지갑/충전/거래내역
│ ├── server-tool.ts # 서버 관리 (시작/중지/재부팅)
│ ├── service-tool.ts # DDoS/VPN 서비스 상태
│ ├── d2-tool.ts # D2 다이어그램 렌더링 (Incus jp1 + R2 캐시)
│ ├── admin-tool.ts # 관리자 기능 (차단, 입금 확인 등)
│ └── knowledge-tool.ts # 지식베이스 검색
├── services/ # 비즈니스 로직 서비스
│ ├── audit.ts # 감사 로그
│ ├── feedback.ts # 피드백 수집/통계
│ ├── human-handoff.ts # 관리자 에스컬레이션
│ ├── notification.ts # 관리자 알림
│ ├── pending-actions.ts # 인프라 변경 승인 워크플로우
│ ├── kv-cache.ts # KV 캐시 추상화
│ └── cron-jobs.ts # 크론 작업 (만료 알림, 아카이빙, 모니터링)
├── routes/ # HTTP 라우팅
│ ├── webhook.ts # Telegram webhook (인증 미들웨어)
│ ├── api.ts # 관리자 API
│ ├── health.ts # 헬스 체크
│ └── handlers/
│ ├── message-handler.ts # 메시지 처리 파이프라인
│ └── callback-handler.ts # 인라인 키보드 콜백
├── constants/agent-config.ts # 에이전트별 설정 (TTL, 토큰, 온도)
├── i18n/ # 다국어 (ko, en)
└── utils/ # 유틸리티
├── logger.ts # 구조화 JSON 로깅 + PII 마스킹
├── patterns.ts # 의도 감지 정규식 패턴
├── session-manager.ts # 제네릭 D1 세션 CRUD + TTL
├── circuit-breaker.ts # 서킷 브레이커 패턴
├── retry.ts # 지수 백오프 재시도
├── optimistic-lock.ts # 낙관적 잠금 (금융 데이터)
├── metrics.ts # 메트릭 수집기
├── env-validation.ts # Zod 환경 변수 검증
└── api-urls.ts # OpenAI/D2 API URL 헬퍼
```
## Key Patterns
- **BaseAgent**: multi-round (도구 실행 -> AI 재호출) vs single-shot (도구 호출 반환 -> 외부 실행)
- **Agent Registry**: 우선순위 순 활성 세션 확인. `__PASSTHROUGH__` → 다음 에이전트로 이관
- **Session markers**: `__SESSION_END__`, `[세션 종료]`, `__ESCALATE__`, `__PASSTHROUGH__`
- **Tool selection**: 메시지 패턴 → 카테고리 → 필요한 도구만 OpenAI에 전달 (토큰 절약)
- **Pending actions**: 위험한 인프라 변경은 `pending_actions` 테이블에 저장 → 사용자/관리자 승인 후 실행
- **AI fallback**: OpenAI 실패 시 Workers AI (llama-3.1-8b-instruct-fp8)
## Bindings (wrangler.toml)
- `DB`: D1 데이터베이스
- `AI`: Workers AI
- `RATE_LIMIT_KV`, `SESSION_KV`, `CACHE_KV`: KV 네임스페이스
- `ASSETS_BUCKET`: R2 버킷
- `BOT_TOKEN`, `WEBHOOK_SECRET`, `OPENAI_API_KEY`: 시크릿
- `ADMIN_TELEGRAM_IDS`: 쉼표 구분 관리자 ID
- `CLOUD_ORCHESTRATOR`: 서비스 바인딩
## Testing
Vitest + Miniflare. 테스트 setup에서 D1 스키마 자동 초기화.
```bash
npm test # 전체 실행
npx vitest run tests/security.test.ts # 단일 파일
```

4352
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "telegram-ai-support",
"version": "1.0.0",
"description": "Telegram AI customer support system with Cloudflare Workers + D1",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:create": "wrangler d1 create telegram-ai-support",
"db:init": "wrangler d1 execute telegram-ai-support --file=schema.sql",
"db:init:local": "wrangler d1 execute telegram-ai-support --local --file=schema.sql",
"tail": "wrangler tail",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.12.10",
"@cloudflare/workers-types": "^4.20241127.0",
"@vitest/coverage-v8": "^2.1.9",
"miniflare": "^3.20231030.0",
"typescript": "^5.3.3",
"vitest": "^2.1.9",
"wrangler": "^4.63.0"
},
"dependencies": {
"hono": "^4.11.7",
"zod": "^4.3.5"
}
}

337
schema.sql Normal file
View File

@@ -0,0 +1,337 @@
-- Telegram AI Support Schema
-- D1 Database for Cloudflare Workers
-- Created: 2026-02-11
----------------------------------------------------------------------
-- Core Tables
----------------------------------------------------------------------
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id TEXT UNIQUE NOT NULL,
username TEXT,
first_name TEXT,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
language_code TEXT DEFAULT 'ko' CHECK(language_code IN ('ko', 'en', 'cn', 'jp')),
context_limit INTEGER DEFAULT 10,
last_active_at DATETIME,
is_blocked INTEGER DEFAULT 0,
blocked_reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
----------------------------------------------------------------------
-- Financial Tables (예치금/결제)
----------------------------------------------------------------------
-- 예치금 계정
CREATE TABLE IF NOT EXISTS wallets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
balance INTEGER NOT NULL DEFAULT 0,
currency TEXT DEFAULT 'KRW',
version INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 거래 내역
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal', 'refund', 'charge')),
amount INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'confirmed', 'rejected', 'cancelled')),
depositor_name TEXT,
depositor_name_prefix TEXT,
description TEXT,
reference_type TEXT,
reference_id INTEGER,
confirmed_by INTEGER,
confirmed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (confirmed_by) REFERENCES users(id)
);
-- 은행 입금 알림 (SMS 파싱)
CREATE TABLE IF NOT EXISTS bank_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_name TEXT,
depositor_name TEXT NOT NULL,
depositor_name_prefix TEXT,
amount INTEGER NOT NULL,
balance_after INTEGER,
transaction_time DATETIME,
raw_message TEXT,
matched_transaction_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (matched_transaction_id) REFERENCES transactions(id)
);
----------------------------------------------------------------------
-- Asset Tables (자산 관리)
----------------------------------------------------------------------
-- 도메인
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
domain TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'expired', 'pending', 'suspended')),
registrar TEXT,
nameservers TEXT,
auto_renew INTEGER DEFAULT 1,
expiry_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 서버
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL DEFAULT 'anvil',
instance_id TEXT,
label TEXT,
ip_address TEXT,
region TEXT,
spec_label TEXT,
monthly_price INTEGER,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'running', 'stopped', 'terminated', 'failed')),
image TEXT,
provisioned_at DATETIME,
terminated_at DATETIME,
expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- DDoS 방어 서비스
CREATE TABLE IF NOT EXISTS services_ddos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
target TEXT NOT NULL,
protection_level TEXT DEFAULT 'basic' CHECK(protection_level IN ('basic', 'standard', 'premium')),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'suspended')),
provider TEXT,
monthly_price INTEGER,
expiry_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- VPN 서비스
CREATE TABLE IF NOT EXISTS services_vpn (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
protocol TEXT DEFAULT 'wireguard' CHECK(protocol IN ('wireguard', 'openvpn', 'ipsec')),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'suspended')),
endpoint TEXT,
monthly_price INTEGER,
expiry_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
----------------------------------------------------------------------
-- Support Tables
----------------------------------------------------------------------
-- 피드백
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
session_type TEXT NOT NULL,
rating INTEGER CHECK(rating BETWEEN 1 AND 5),
comment TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 보류 중인 작업 (인프라 변경 승인)
CREATE TABLE IF NOT EXISTS pending_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
action_type TEXT NOT NULL,
target TEXT NOT NULL,
params TEXT NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'rejected', 'executed', 'failed')),
approved_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
executed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (approved_by) REFERENCES users(id)
);
-- 감사 로그 (immutable)
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
details TEXT,
result TEXT NOT NULL CHECK(result IN ('success', 'failure')),
request_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (actor_id) REFERENCES users(id)
);
-- 지식 베이스
CREATE TABLE IF NOT EXISTS knowledge_articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT,
language TEXT DEFAULT 'ko',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
----------------------------------------------------------------------
-- Cache Tables
----------------------------------------------------------------------
-- D2 렌더링 캐시 메타데이터
CREATE TABLE IF NOT EXISTS d2_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_hash TEXT UNIQUE NOT NULL,
r2_key TEXT NOT NULL,
format TEXT DEFAULT 'svg' CHECK(format IN ('svg', 'png')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
----------------------------------------------------------------------
-- Conversation Tables
----------------------------------------------------------------------
-- 대화 이력 (최근 대화 저장)
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
tool_calls TEXT,
tool_results TEXT,
request_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 대화 아카이브 요약 (90일 이후)
CREATE TABLE IF NOT EXISTS conversation_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
summary TEXT NOT NULL,
message_count INTEGER NOT NULL,
period_start DATETIME NOT NULL,
period_end DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
----------------------------------------------------------------------
-- Agent Session Tables
----------------------------------------------------------------------
-- 온보딩 상담 세션
CREATE TABLE IF NOT EXISTS onboarding_sessions (
user_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'greeting' CHECK(status IN ('greeting', 'gathering', 'suggesting', 'completed')),
collected_info TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- 트러블슈팅 상담 세션
CREATE TABLE IF NOT EXISTS troubleshoot_sessions (
user_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'gathering' CHECK(status IN ('gathering', 'diagnosing', 'suggesting', 'escalated', 'completed')),
collected_info TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
escalation_count INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- 자산 조회 세션
CREATE TABLE IF NOT EXISTS asset_sessions (
user_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle', 'viewing', 'managing', 'completed')),
collected_info TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- 결제 세션
CREATE TABLE IF NOT EXISTS billing_sessions (
user_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'collecting_amount' CHECK(status IN ('collecting_amount', 'collecting_name', 'confirming', 'completed')),
collected_info TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
----------------------------------------------------------------------
-- Indexes
----------------------------------------------------------------------
-- Core
CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(last_active_at DESC);
-- Financial
CREATE INDEX IF NOT EXISTS idx_wallets_user ON wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id);
CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_transactions_prefix_pending ON transactions(status, type, depositor_name_prefix, amount, created_at)
WHERE status = 'pending' AND type = 'deposit';
CREATE INDEX IF NOT EXISTS idx_bank_notif_prefix ON bank_notifications(depositor_name_prefix, amount, created_at DESC)
WHERE matched_transaction_id IS NULL;
-- Assets
CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(user_id);
CREATE INDEX IF NOT EXISTS idx_domains_expiry ON domains(expiry_date);
CREATE INDEX IF NOT EXISTS idx_servers_user ON servers(user_id);
CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status);
CREATE INDEX IF NOT EXISTS idx_ddos_user ON services_ddos(user_id);
CREATE INDEX IF NOT EXISTS idx_vpn_user ON services_vpn(user_id);
-- Support
CREATE INDEX IF NOT EXISTS idx_feedback_user ON feedback(user_id);
CREATE INDEX IF NOT EXISTS idx_pending_actions_status ON pending_actions(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_request ON audit_logs(request_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge_articles(category, is_active);
-- Cache
CREATE INDEX IF NOT EXISTS idx_d2_cache_hash ON d2_cache(source_hash);
-- Conversations
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_archives_user ON conversation_archives(user_id, period_end DESC);
-- Agent Sessions
CREATE INDEX IF NOT EXISTS idx_onboarding_expires ON onboarding_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_troubleshoot_expires ON troubleshoot_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_asset_expires ON asset_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_billing_expires ON billing_sessions(expires_at);

View File

@@ -0,0 +1,83 @@
/**
* Agent Registry - 데이터 기반 에이전트 라우팅
*
* 등록된 에이전트를 순회하며 활성 세션이 있는 에이전트로 라우팅합니다.
* PASSTHROUGH 응답 시 다음 에이전트를 확인하고,
* 에러 시 해당 에이전트를 건너뛰고 계속 진행합니다.
*/
import type { Env } from '../types';
import { createLogger } from '../utils/logger';
const logger = createLogger('agent-registry');
export interface RegisterableAgent {
hasSession(db: D1Database, userId: string): Promise<boolean>;
processConsultation(
db: D1Database, userId: string, userMessage: string, env: Env
): Promise<string>;
}
interface AgentEntry {
name: string;
agent: RegisterableAgent;
priority: number;
}
const registry: AgentEntry[] = [];
/**
* 에이전트를 레지스트리에 등록합니다.
* priority가 낮을수록 먼저 확인됩니다.
*/
export function registerAgent(
name: string,
agent: RegisterableAgent,
priority: number = 0
): void {
registry.push({ name, agent, priority });
registry.sort((a, b) => a.priority - b.priority);
}
/**
* 활성 세션이 있는 에이전트를 찾아 메시지를 라우팅합니다.
*
* @returns 에이전트 응답 또는 null (세션 없음)
*/
export async function routeToActiveAgent(
db: D1Database,
userId: string,
userMessage: string,
env: Env
): Promise<string | null> {
for (const entry of registry) {
try {
const hasSession = await entry.agent.hasSession(db, userId);
if (!hasSession) continue;
logger.info('세션 감지, 에이전트로 라우팅', {
agent: entry.name,
userId,
});
const response = await entry.agent.processConsultation(
db, userId, userMessage, env
);
// PASSTHROUGH: 무관한 메시지, 다음 에이전트 확인
if (response === '__PASSTHROUGH__') {
continue;
}
return response;
} catch (error) {
logger.error('에이전트 라우팅 실패, 다음 에이전트 확인', error as Error, {
agent: entry.name,
userId,
});
continue;
}
}
return null;
}

408
src/agents/asset-agent.ts Normal file
View File

@@ -0,0 +1,408 @@
/**
* Asset Agent - 자산 관리/대시보드 에이전트
*
* 기존 고객의 자산 현황을 조회하고 관리합니다:
* - 잔액, 도메인, 서버, DDoS, VPN 서비스 종합 대시보드
* - 개별 자산 상세 조회 및 관리
* - single-shot 전략: AI가 도구 호출 결정 -> 결과 조합
*/
import type { ToolDefinition, AssetSession, ManageDomainArgs, ManageServerArgs, CheckServiceArgs } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('asset');
const logger = createLogger('asset-agent');
class AssetSessionManager extends SessionManager<AssetSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
}
export class AssetAgent extends BaseAgent<AssetSession> {
protected readonly agentName = 'asset-agent';
protected readonly sessionManager = new AssetSessionManager();
protected getExecutionStrategy() { return 'single-shot' as const; }
protected getInitialStatus() { return 'idle'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: AssetSession): string {
return `당신은 호스팅/인프라 서비스의 자산 관리 도우미입니다.
고객이 보유한 자산(잔액, 도메인, 서버, DDoS, VPN)을 조회하고 관리할 수 있도록 도와줍니다.
## 주요 기능
1. **대시보드**: 전체 자산 요약 조회 (잔액, 서버 수, 도메인 수, 서비스 현황)
2. **도메인 관리**: 도메인 목록, 상세 정보, 네임서버 설정
3. **서버 관리**: 서버 목록, 상태 확인, 시작/중지/재부팅
4. **서비스 조회**: DDoS/VPN 서비스 상태 확인
## 현재 세션 상태: ${session.status}
## 응답 원칙
- 항상 한국어로 응답하세요.
- 자산 정보는 보기 쉽게 정리해서 보여주세요.
- 금액은 원(KRW) 단위로, 천 단위 구분자를 사용하세요.
- 자산 관리와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 조회가 완료되고 추가 요청이 없으면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- get_dashboard: 전체 자산 요약 대시보드
- manage_domain: 도메인 관리 (목록, 상세, 네임서버 설정)
- manage_server: 서버 관리 (목록, 상세, 시작/중지/재부팅)
- check_service: DDoS/VPN 서비스 상태 조회`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'get_dashboard',
description: '고객의 전체 자산 요약 대시보드를 조회합니다. 잔액, 서버 수, 도메인 수, 서비스 현황을 한눈에 보여줍니다.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function',
function: {
name: 'manage_domain',
description: '고객의 도메인을 관리합니다. 목록 조회, 상세 정보, 네임서버 설정 등을 수행합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['list', 'info', 'set_ns'],
description: '수행할 작업',
},
domain: {
type: 'string',
description: '대상 도메인명 (info, set_ns에 필요)',
},
nameservers: {
type: 'array',
items: { type: 'string' },
description: '설정할 네임서버 목록 (set_ns에 필요)',
},
},
required: ['action'],
},
},
},
{
type: 'function',
function: {
name: 'manage_server',
description: '고객의 서버를 관리합니다. 목록 조회, 상태 확인, 시작/중지/재부팅을 수행합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['list', 'info', 'start', 'stop', 'reboot'],
description: '수행할 작업',
},
server_id: {
type: 'number',
description: '대상 서버 ID (info, start, stop, reboot에 필요)',
},
},
required: ['action'],
},
},
},
{
type: 'function',
function: {
name: 'check_service',
description: '고객의 DDoS/VPN 서비스 상태를 조회합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'list'],
description: '수행할 작업',
},
service_type: {
type: 'string',
enum: ['ddos', 'vpn', 'all'],
description: '서비스 유형 (기본값: all)',
},
service_id: {
type: 'number',
description: '특정 서비스 ID (status에 필요)',
},
},
required: ['action'],
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: AssetSession,
context: AgentToolContext
): Promise<string> {
const { userId, db } = context;
switch (name) {
case 'get_dashboard':
return this.handleGetDashboard(userId, db);
case 'manage_domain':
return this.handleManageDomain(userId, args as unknown as ManageDomainArgs, db);
case 'manage_server':
return this.handleManageServer(userId, args as unknown as ManageServerArgs, db);
case 'check_service':
return this.handleCheckService(userId, args as unknown as CheckServiceArgs, db);
default:
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
}
private async handleGetDashboard(userId: string, db: D1Database): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
const [wallet, servers, domains, ddos, vpn] = await Promise.all([
db.prepare(`SELECT balance, currency FROM wallets WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running, SUM(monthly_price) as total_cost FROM servers WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active FROM domains WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, SUM(monthly_price) as total_cost FROM services_ddos WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, SUM(monthly_price) as total_cost FROM services_vpn WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
]);
return JSON.stringify({
wallet: {
balance: wallet?.balance || 0,
currency: wallet?.currency || 'KRW',
},
servers: {
total: servers?.total || 0,
running: servers?.running || 0,
monthly_cost: servers?.total_cost || 0,
},
domains: {
total: domains?.total || 0,
active: domains?.active || 0,
},
ddos_services: {
total: ddos?.total || 0,
active: ddos?.active || 0,
monthly_cost: ddos?.total_cost || 0,
},
vpn_services: {
total: vpn?.total || 0,
active: vpn?.active || 0,
monthly_cost: vpn?.total_cost || 0,
},
});
} catch (error) {
logger.error('대시보드 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '대시보드 정보를 조회할 수 없습니다.' });
}
}
private async handleManageDomain(
userId: string,
args: ManageDomainArgs,
db: D1Database
): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
switch (args.action) {
case 'list': {
const domains = await db.prepare(
`SELECT id, domain, status, expiry_date, auto_renew
FROM domains WHERE user_id = ${userIdSubquery}
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
domains: domains.results || [],
count: domains.results?.length || 0,
});
}
case 'info': {
if (!args.domain) {
return JSON.stringify({ error: '도메인명을 지정해주세요.' });
}
const domain = await db.prepare(
`SELECT id, domain, status, registrar, nameservers, auto_renew, expiry_date, created_at
FROM domains WHERE user_id = ${userIdSubquery} AND domain = ?`
).bind(userId, args.domain).first();
if (!domain) {
return JSON.stringify({ error: '해당 도메인을 찾을 수 없습니다.' });
}
return JSON.stringify({ domain });
}
case 'set_ns': {
if (!args.domain || !args.nameservers) {
return JSON.stringify({ error: '도메인명과 네임서버 목록이 필요합니다.' });
}
// Create pending action for admin approval
await db.prepare(
`INSERT INTO pending_actions (user_id, action_type, target, params, status)
VALUES (${userIdSubquery}, 'set_nameservers', ?, ?, 'pending')`
).bind(userId, args.domain, JSON.stringify({ nameservers: args.nameservers })).run();
return JSON.stringify({
message: '네임서버 변경 요청이 접수되었습니다. 관리자 승인 후 적용됩니다.',
});
}
default:
return JSON.stringify({ error: `지원하지 않는 작업: ${args.action}` });
}
} catch (error) {
logger.error('도메인 관리 오류', error as Error, { userId });
return JSON.stringify({ error: '도메인 관리 중 오류가 발생했습니다.' });
}
}
private async handleManageServer(
userId: string,
args: ManageServerArgs,
db: D1Database
): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
switch (args.action) {
case 'list': {
const servers = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price
FROM servers WHERE user_id = ${userIdSubquery}
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
servers: servers.results || [],
count: servers.results?.length || 0,
});
}
case 'info': {
if (!args.server_id) {
return JSON.stringify({ error: '서버 ID를 지정해주세요.' });
}
const server = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price, provider, image, provisioned_at, expires_at
FROM servers WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.server_id).first();
if (!server) {
return JSON.stringify({ error: '해당 서버를 찾을 수 없습니다.' });
}
return JSON.stringify({ server });
}
case 'start':
case 'stop':
case 'reboot': {
if (!args.server_id) {
return JSON.stringify({ error: '서버 ID를 지정해주세요.' });
}
// Verify server belongs to user
const server = await db.prepare(
`SELECT id, status FROM servers WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.server_id).first();
if (!server) {
return JSON.stringify({ error: '해당 서버를 찾을 수 없습니다.' });
}
// Create pending action for admin approval
await db.prepare(
`INSERT INTO pending_actions (user_id, action_type, target, params, status)
VALUES (${userIdSubquery}, ?, ?, ?, 'pending')`
).bind(userId, `server_${args.action}`, `server:${args.server_id}`, JSON.stringify({ server_id: args.server_id })).run();
const actionLabels: Record<string, string> = {
start: '시작',
stop: '중지',
reboot: '재부팅',
};
return JSON.stringify({
message: `서버 ${actionLabels[args.action]} 요청이 접수되었습니다. 관리자 승인 후 실행됩니다.`,
});
}
default:
return JSON.stringify({ error: `지원하지 않는 작업: ${args.action}` });
}
} catch (error) {
logger.error('서버 관리 오류', error as Error, { userId });
return JSON.stringify({ error: '서버 관리 중 오류가 발생했습니다.' });
}
}
private async handleCheckService(
userId: string,
args: CheckServiceArgs,
db: D1Database
): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
const result: Record<string, unknown> = {};
const serviceType = args.service_type || 'all';
if (serviceType === 'ddos' || serviceType === 'all') {
if (args.service_id && serviceType === 'ddos') {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, provider, monthly_price, expiry_date
FROM services_ddos WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.service_id).first();
result.ddos = ddos || { error: '해당 DDoS 서비스를 찾을 수 없습니다.' };
} else {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, monthly_price, expiry_date
FROM services_ddos WHERE user_id = ${userIdSubquery}`
).bind(userId).all();
result.ddos_services = ddos.results || [];
}
}
if (serviceType === 'vpn' || serviceType === 'all') {
if (args.service_id && serviceType === 'vpn') {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.service_id).first();
result.vpn = vpn || { error: '해당 VPN 서비스를 찾을 수 없습니다.' };
} else {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = ${userIdSubquery}`
).bind(userId).all();
result.vpn_services = vpn.results || [];
}
}
return JSON.stringify(result);
} catch (error) {
logger.error('서비스 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '서비스 정보를 조회할 수 없습니다.' });
}
}
}

306
src/agents/base-agent.ts Normal file
View File

@@ -0,0 +1,306 @@
/**
* Base Agent - 모든 에이전트의 추상 기반 클래스
*
* 세션 라이프사이클, AI 호출 루프, 마커 처리를 통합하여
* 각 에이전트는 도메인 로직(프롬프트, 도구, 실행)만 구현하면 됩니다.
*
* 실행 전략:
* - multi-round: 도구 실행 -> 결과를 AI에 다시 전달 -> 최종 응답
* - single-shot: AI가 도구 호출 반환 -> 외부 실행 -> 결과 조합
*/
import type { Env, OpenAIToolCall, OpenAIAPIResponse, ToolDefinition } from '../types';
import type { BaseSession } from '../utils/session-manager';
import { SessionManager } from '../utils/session-manager';
import { createLogger } from '../utils/logger';
import { getOpenAIUrl } from '../utils/api-urls';
import { AI_CONFIG } from '../constants/agent-config';
export type ExecutionStrategy = 'multi-round' | 'single-shot';
export interface AgentToolContext {
userId: string;
env: Env;
db: D1Database;
}
export interface AgentAIResult {
response: string;
calledTools: string[];
toolCalls?: Array<{ name: string; arguments: Record<string, unknown> }>;
}
export abstract class BaseAgent<TSession extends BaseSession> {
protected abstract readonly agentName: string;
protected abstract readonly sessionManager: SessionManager<TSession>;
// --- Must implement ---
protected abstract getSystemPrompt(session: TSession): string;
protected abstract getTools(): ToolDefinition[];
protected abstract executeToolCall(
name: string,
args: Record<string, unknown>,
session: TSession,
context: AgentToolContext
): Promise<string>;
// --- Overridable hooks ---
protected getExecutionStrategy(): ExecutionStrategy { return 'multi-round'; }
protected getInitialStatus(): string { return 'gathering'; }
protected getMaxTokens(): number { return 800; }
protected getTemperature(): number { return 0.7; }
protected getErrorMessage(): string {
return '죄송합니다. 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
protected getMaxToolCallsMessage(): string { return '처리가 완료되었습니다.'; }
protected buildPromptSuffix(session: TSession): string {
return `\n\n## 현재 수집된 정보\n${JSON.stringify(session.collected_info, null, 2)}`;
}
protected async onSessionEnd(_session: TSession, _db: D1Database): Promise<void> {}
protected onBeforeSave(_session: TSession, _response: string, _calledTools: string[]): void {}
// --- Public API ---
async hasSession(db: D1Database, userId: string): Promise<boolean> {
return this.sessionManager.has(db, userId);
}
async processConsultation(
db: D1Database,
userId: string,
userMessage: string,
env: Env
): Promise<string> {
const log = createLogger(this.agentName);
const startTime = Date.now();
log.info('상담 시작', { userId, message: userMessage.substring(0, 100) });
try {
// 1. Get or create session
let session = await this.sessionManager.get(db, userId);
if (!session) {
session = this.sessionManager.create(userId, this.getInitialStatus());
}
// 2. Add user message
this.sessionManager.addMessage(session, 'user', userMessage);
// 3. Call AI
const context: AgentToolContext = { userId, env, db };
const aiResult = await this.callExpertAI(session, userMessage, env, context);
// 4. Handle PASSTHROUGH
if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) {
log.info('패스스루', { userId });
return '__PASSTHROUGH__';
}
// 5. Single-shot: execute tool calls returned by AI
let finalResponse = aiResult.response;
if (this.getExecutionStrategy() === 'single-shot'
&& aiResult.toolCalls && aiResult.toolCalls.length > 0) {
const toolResults: string[] = [];
for (const tc of aiResult.toolCalls) {
const result = await this.executeToolCall(tc.name, tc.arguments, session, context);
toolResults.push(result);
aiResult.calledTools.push(tc.name);
}
if (toolResults.length > 0) {
const cleanAiText = (aiResult.response || '')
.replace('__SESSION_END__', '').trim();
finalResponse = cleanAiText
? cleanAiText + '\n\n' + toolResults.join('\n\n')
: toolResults.join('\n\n');
}
}
// 6. Detect session end (both markers)
const isSessionEnd = finalResponse.includes('[세션 종료]')
|| aiResult.response.includes('__SESSION_END__');
if (isSessionEnd) {
finalResponse = finalResponse
.replace('[세션 종료]', '').replace('__SESSION_END__', '').trim();
log.info('세션 종료', { userId });
await this.onSessionEnd(session, db);
await this.sessionManager.delete(db, userId);
return finalResponse;
}
// 7. Before save hook
this.onBeforeSave(session, finalResponse, aiResult.calledTools);
// 8. Save session
this.sessionManager.addMessage(session, 'assistant', finalResponse);
await this.sessionManager.save(db, session);
log.info('상담 완료', {
userId,
duration: Date.now() - startTime,
calledTools: aiResult.calledTools.length,
});
return finalResponse;
} catch (error) {
log.error('상담 오류', error as Error, { userId });
return this.getErrorMessage();
}
}
// --- AI Call Loop ---
protected async callExpertAI(
session: TSession,
userMessage: string,
env: Env,
context: AgentToolContext
): Promise<AgentAIResult> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const log = createLogger(this.agentName);
const strategy = this.getExecutionStrategy();
const conversationHistory = session.messages.map(m => ({
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
content: m.content,
}));
const systemPrompt = this.getSystemPrompt(session) + this.buildPromptSuffix(session);
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_ROUNDS = AI_CONFIG.maxToolCalls;
let round = 0;
const calledTools: string[] = [];
while (round < MAX_ROUNDS) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000);
let response: Response;
try {
response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify({
model: AI_CONFIG.model,
messages,
tools: this.getTools(),
tool_choice: 'auto',
max_tokens: this.getMaxTokens(),
temperature: this.getTemperature(),
}),
});
if (!response.ok) {
const errorText = await response.text();
log.error('OpenAI API error', new Error(`HTTP ${response.status}`), {
status: response.status,
responsePreview: errorText.substring(0, 500),
});
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json() as OpenAIAPIResponse;
const assistantMessage = data.choices[0].message;
// Tool calls
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
log.info('도구 호출 요청', {
tools: assistantMessage.tool_calls.map(tc => tc.function.name),
});
// Single-shot: return tool calls without executing
if (strategy === 'single-shot') {
return {
response: assistantMessage.content || '',
calledTools,
toolCalls: assistantMessage.tool_calls.map(tc => ({
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments) as Record<string, unknown>,
})),
};
}
// Multi-round: execute and feed back to AI
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
});
for (const toolCall of assistantMessage.tool_calls) {
let args: Record<string, unknown>;
try {
args = JSON.parse(toolCall.function.arguments);
} catch (parseError) {
log.error('도구 인자 파싱 실패', parseError as Error, {
toolName: toolCall.function.name,
});
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: JSON.stringify({ error: '도구 인자 파싱 실패' }),
});
continue;
}
const result = await this.executeToolCall(
toolCall.function.name, args, session, context
);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
});
calledTools.push(toolCall.function.name);
}
round++;
continue;
}
// No tool calls - final response
const aiResponse = assistantMessage.content || '';
log.info('AI 응답', { response: aiResponse.slice(0, 200) });
if (aiResponse.includes('__PASSTHROUGH__')) {
return { response: '__PASSTHROUGH__', calledTools };
}
const sessionEnd = aiResponse.includes('__SESSION_END__');
const cleanResponse = aiResponse.replace('__SESSION_END__', '').trim();
return {
response: sessionEnd ? `${cleanResponse}\n\n[세션 종료]` : cleanResponse,
calledTools,
};
} finally {
clearTimeout(timeoutId);
}
}
log.warn('최대 도구 호출 라운드 도달', { round });
return { response: this.getMaxToolCallsMessage(), calledTools };
}
}

292
src/agents/billing-agent.ts Normal file
View File

@@ -0,0 +1,292 @@
/**
* Billing Agent - 예치금/결제 관리 에이전트
*
* 고객의 예치금 및 결제를 관리합니다:
* - 잔액 조회, 입금 계좌 안내
* - 입금 요청 (입금자명/금액 수집)
* - 거래 내역 조회
* - 입금 요청 취소
* - 금융 거래 시 optimistic locking
*/
import type { Env, ToolDefinition, BillingSession, ManageWalletArgs } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('billing');
const logger = createLogger('billing-agent');
class BillingSessionManager extends SessionManager<BillingSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
}
export class BillingAgent extends BaseAgent<BillingSession> {
protected readonly agentName = 'billing-agent';
protected readonly sessionManager = new BillingSessionManager();
protected getExecutionStrategy() { return 'single-shot' as const; }
protected getInitialStatus() { return 'collecting_amount'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: BillingSession): string {
return `당신은 호스팅/인프라 서비스의 결제 도우미입니다.
고객의 예치금 잔액 확인, 입금 요청, 거래 내역 조회를 도와줍니다.
## 주요 기능
1. **잔액 조회**: 현재 예치금 잔액 확인
2. **입금 계좌 안내**: 입금 계좌 정보 제공
3. **입금 요청**: 입금자명과 금액을 수집하여 입금 요청 생성
4. **거래 내역**: 최근 거래 내역 조회
5. **요청 취소**: 대기 중인 입금 요청 취소
## 입금 요청 프로세스
1. 고객에게 입금 금액 확인
2. 입금자명 확인 (실명)
3. 금액과 입금자명을 확인 후 입금 요청 생성
4. 입금 계좌 정보 안내
## 현재 세션 상태: ${session.status}
## 대화 원칙
- 항상 한국어로 응답하세요.
- 금액은 원(KRW) 단위로 표시하고, 천 단위 구분자를 사용하세요.
- 금융 관련이므로 정확한 금액 확인이 중요합니다. 항상 확인 절차를 거치세요.
- 결제와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 작업이 완료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- manage_wallet: 잔액 조회, 계좌 안내, 입금 요청, 거래 내역, 요청 취소`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'manage_wallet',
description: '고객의 예치금 지갑을 관리합니다. 잔액 조회, 입금 계좌 안내, 입금 요청, 거래 내역 조회, 요청 취소를 수행합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['balance', 'account', 'request', 'history', 'cancel'],
description: '수행할 작업: balance(잔액), account(계좌안내), request(입금요청), history(내역), cancel(취소)',
},
depositor_name: {
type: 'string',
description: '입금자명 (request에 필요)',
},
amount: {
type: 'number',
description: '입금 금액 (request에 필요)',
},
transaction_id: {
type: 'number',
description: '거래 ID (cancel에 필요)',
},
limit: {
type: 'number',
description: '조회 건수 제한 (history, 기본값: 10)',
},
},
required: ['action'],
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: BillingSession,
context: AgentToolContext
): Promise<string> {
if (name !== 'manage_wallet') {
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
const walletArgs = args as unknown as ManageWalletArgs;
const { userId, db, env } = context;
switch (walletArgs.action) {
case 'balance':
return this.handleBalance(userId, db);
case 'account':
return this.handleAccount(env);
case 'request':
return this.handleRequest(userId, walletArgs, db);
case 'history':
return this.handleHistory(userId, walletArgs.limit, db);
case 'cancel':
return this.handleCancel(userId, walletArgs.transaction_id, db);
default:
return JSON.stringify({ error: `지원하지 않는 작업: ${walletArgs.action}` });
}
}
private async handleBalance(userId: string, db: D1Database): Promise<string> {
try {
const wallet = await db.prepare(
`SELECT balance, currency, updated_at FROM wallets
WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)`
).bind(userId).first();
if (!wallet) {
return JSON.stringify({
balance: 0,
currency: 'KRW',
message: '지갑이 아직 생성되지 않았습니다.',
});
}
return JSON.stringify({
balance: wallet.balance,
currency: wallet.currency,
last_updated: wallet.updated_at,
});
} catch (error) {
logger.error('잔액 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '잔액 정보를 조회할 수 없습니다.' });
}
}
private handleAccount(env: Env): string {
const bankName = env.DEPOSIT_BANK_NAME || '(설정 필요)';
const bankAccount = env.DEPOSIT_BANK_ACCOUNT || '(설정 필요)';
const bankHolder = env.DEPOSIT_BANK_HOLDER || '(설정 필요)';
return JSON.stringify({
bank_name: bankName,
account_number: bankAccount,
account_holder: bankHolder,
notice: '입금 시 입금자명을 정확히 기재해주세요. 입금 확인은 영업일 기준 1~2시간 이내에 처리됩니다.',
});
}
private async handleRequest(
userId: string,
args: ManageWalletArgs,
db: D1Database
): Promise<string> {
if (!args.depositor_name || !args.amount) {
return JSON.stringify({ error: '입금자명과 금액이 필요합니다.' });
}
if (args.amount <= 0) {
return JSON.stringify({ error: '입금 금액은 0보다 커야 합니다.' });
}
if (args.amount > 10_000_000) {
return JSON.stringify({ error: '1회 최대 입금 금액은 10,000,000원입니다.' });
}
try {
// Generate name prefix for auto-matching (first 2 chars)
const namePrefix = args.depositor_name.substring(0, 2);
const result = await db.prepare(
`INSERT INTO transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
VALUES ((SELECT id FROM users WHERE telegram_id = ?), 'deposit', ?, 'pending', ?, ?, '텔레그램 봇 입금 요청')`
).bind(userId, args.amount, args.depositor_name, namePrefix).run();
if (!result.success) {
return JSON.stringify({ error: '입금 요청 생성에 실패했습니다.' });
}
return JSON.stringify({
success: true,
transaction_id: result.meta.last_row_id,
depositor_name: args.depositor_name,
amount: args.amount,
status: 'pending',
message: '입금 요청이 생성되었습니다. 입금 후 자동으로 확인됩니다.',
});
} catch (error) {
logger.error('입금 요청 생성 오류', error as Error, { userId });
return JSON.stringify({ error: '입금 요청 생성 중 오류가 발생했습니다.' });
}
}
private async handleHistory(
userId: string,
limit: number | undefined,
db: D1Database
): Promise<string> {
try {
const queryLimit = Math.min(limit || 10, 50);
const transactions = await db.prepare(
`SELECT id, type, amount, status, depositor_name, description, created_at, confirmed_at
FROM transactions
WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)
ORDER BY created_at DESC
LIMIT ?`
).bind(userId, queryLimit).all();
return JSON.stringify({
transactions: transactions.results || [],
count: transactions.results?.length || 0,
});
} catch (error) {
logger.error('거래 내역 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '거래 내역을 조회할 수 없습니다.' });
}
}
private async handleCancel(
userId: string,
transactionId: number | undefined,
db: D1Database
): Promise<string> {
if (!transactionId) {
return JSON.stringify({ error: '취소할 거래 ID를 지정해주세요.' });
}
try {
// Verify transaction belongs to user and is cancellable
const transaction = await db.prepare(
`SELECT id, status, amount FROM transactions
WHERE id = ? AND user_id = (SELECT id FROM users WHERE telegram_id = ?)
AND type = 'deposit' AND status = 'pending'`
).bind(transactionId, userId).first();
if (!transaction) {
return JSON.stringify({
error: '취소할 수 있는 거래를 찾을 수 없습니다. 대기(pending) 상태의 입금 요청만 취소 가능합니다.',
});
}
// Use optimistic locking via status check in WHERE clause
const result = await db.prepare(
`UPDATE transactions SET status = 'cancelled'
WHERE id = ? AND status = 'pending'`
).bind(transactionId).run();
if (!result.success || result.meta.changes === 0) {
return JSON.stringify({ error: '거래 취소에 실패했습니다. 이미 처리된 거래일 수 있습니다.' });
}
return JSON.stringify({
success: true,
transaction_id: transactionId,
amount: transaction.amount,
message: '입금 요청이 취소되었습니다.',
});
} catch (error) {
logger.error('거래 취소 오류', error as Error, { userId, transactionId });
return JSON.stringify({ error: '거래 취소 중 오류가 발생했습니다.' });
}
}
}

View File

@@ -0,0 +1,208 @@
/**
* Onboarding Agent - 신규 고객 상담 에이전트
*
* 서비스를 잘 모르는 신규 고객을 대상으로:
* - 사용 가능한 서비스 안내 (서버, 도메인, DDoS 방어, VPN)
* - 고객 니즈 파악 및 적절한 플랜 추천
* - D2 다이어그램으로 아키텍처 시각화
* - 지식 베이스 검색
*/
import type { Env, ToolDefinition, OnboardingSession, RenderD2Args } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { getD2RenderUrl } from '../utils/api-urls';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('onboarding');
const logger = createLogger('onboarding-agent');
class OnboardingSessionManager extends SessionManager<OnboardingSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
}
export class OnboardingAgent extends BaseAgent<OnboardingSession> {
protected readonly agentName = 'onboarding-agent';
protected readonly sessionManager = new OnboardingSessionManager();
protected getExecutionStrategy() { return 'multi-round' as const; }
protected getInitialStatus() { return 'greeting'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: OnboardingSession): string {
return `당신은 호스팅/인프라 서비스 회사의 친절한 고객 상담 AI입니다.
신규 고객이 서비스를 이해하고 적절한 플랜을 선택할 수 있도록 도와주세요.
## 제공 서비스
1. **서버 호스팅**: 클라우드 VPS, 전용 서버 (다양한 리전: 한국, 일본, 미국, 유럽)
2. **도메인 관리**: 도메인 등록, 이전, DNS 관리
3. **DDoS 방어**: Basic/Standard/Premium 등급 방어 서비스
4. **VPN 서비스**: WireGuard, OpenVPN, IPsec 프로토콜 지원
## 상담 프로세스
1. 고객 인사 및 니즈 파악
2. 사용 목적 확인 (웹 호스팅, 게임 서버, API 서버, 스트리밍 등)
3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항)
4. 예산 범위 확인
5. 적절한 서비스 조합 추천
6. 필요 시 D2 다이어그램으로 아키텍처 시각화
## 현재 세션 상태: ${session.status}
## 대화 원칙
- 항상 한국어로 응답하세요.
- 친절하고 전문적인 톤을 유지하세요.
- 고객이 기술에 익숙하지 않을 수 있으므로 쉬운 용어를 사용하세요.
- 한 번에 너무 많은 질문을 하지 마세요 (최대 2개).
- 고객의 니즈와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- 아키텍처 설명 시 render_d2로 다이어그램을 생성할 수 있습니다.
- 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'render_d2',
description: '아키텍처 다이어그램을 D2 언어로 렌더링합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.',
parameters: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'D2 다이어그램 소스 코드',
},
format: {
type: 'string',
enum: ['svg', 'png'],
description: '출력 형식 (기본값: svg)',
},
},
required: ['source'],
},
},
},
{
type: 'function',
function: {
name: 'search_knowledge',
description: '지식 베이스에서 서비스 관련 정보를 검색합니다. 가격, 사양, 정책, FAQ 등을 조회할 때 사용합니다.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '검색 키워드 또는 질문',
},
category: {
type: 'string',
enum: ['server', 'domain', 'ddos', 'vpn', 'billing', 'general'],
description: '검색 카테고리 (선택)',
},
},
required: ['query'],
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: OnboardingSession,
context: AgentToolContext
): Promise<string> {
switch (name) {
case 'render_d2':
return this.handleRenderD2(args as unknown as RenderD2Args, context.env);
case 'search_knowledge':
return this.handleSearchKnowledge(
args as { query: string; category?: string },
context.db
);
default:
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
}
private async handleRenderD2(args: RenderD2Args, env: Env): Promise<string> {
try {
const renderUrl = getD2RenderUrl(env);
const response = await fetch(`${renderUrl}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: args.source,
format: args.format || 'svg',
}),
});
if (!response.ok) {
logger.error('D2 렌더링 실패', new Error(`HTTP ${response.status}`));
return JSON.stringify({ error: 'D2 다이어그램 렌더링에 실패했습니다.' });
}
return JSON.stringify({
success: true,
message: '아키텍처 다이어그램이 생성되었습니다.',
format: args.format || 'svg',
});
} catch (error) {
logger.error('D2 렌더링 오류', error as Error);
return JSON.stringify({ error: 'D2 렌더링 서비스에 연결할 수 없습니다.' });
}
}
private async handleSearchKnowledge(
args: { query: string; category?: string },
db: D1Database
): Promise<string> {
try {
let sql = `SELECT title, content, category FROM knowledge_articles WHERE is_active = 1`;
const params: string[] = [];
if (args.category) {
sql += ` AND category = ?`;
params.push(args.category);
}
// Simple keyword search using LIKE
sql += ` AND (title LIKE ? OR content LIKE ? OR tags LIKE ?)`;
const keyword = `%${args.query}%`;
params.push(keyword, keyword, keyword);
sql += ` ORDER BY updated_at DESC LIMIT 5`;
const results = await db.prepare(sql).bind(...params).all();
if (!results.results || results.results.length === 0) {
return JSON.stringify({ results: [], message: '관련 문서를 찾을 수 없습니다.' });
}
const articles = results.results.map(r => ({
title: r.title,
content: (r.content as string).substring(0, 300),
category: r.category,
}));
return JSON.stringify({ results: articles });
} catch (error) {
logger.error('지식 베이스 검색 오류', error as Error);
return JSON.stringify({ error: '지식 베이스 검색에 실패했습니다.' });
}
}
}

View File

@@ -0,0 +1,295 @@
/**
* Troubleshoot Agent - 기존 고객 문제 해결 에이전트
*
* 기존 고객의 기술적 문제를 진단하고 해결합니다:
* - 체계적 정보 수집 (카테고리, 증상, 환경, 에러)
* - 상태 -> 원인 분석 -> 해결책 -> 예측 프로세스
* - 서버/도메인/서비스 상태 조회
* - 3라운드 이내 해결 불가 시 에스컬레이션
*/
import type { ToolDefinition, TroubleshootSession } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('troubleshoot');
const logger = createLogger('troubleshoot-agent');
class TroubleshootSessionManager extends SessionManager<TroubleshootSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
protected parseAdditionalFields(result: Record<string, unknown>): Partial<TroubleshootSession> {
return {
escalation_count: (result.escalation_count as number) || 0,
};
}
protected getAdditionalColumns(session: TroubleshootSession): Record<string, unknown> {
return {
escalation_count: session.escalation_count || 0,
};
}
}
export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
protected readonly agentName = 'troubleshoot-agent';
protected readonly sessionManager = new TroubleshootSessionManager();
protected getExecutionStrategy() { return 'multi-round' as const; }
protected getInitialStatus() { return 'gathering'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: TroubleshootSession): string {
return `당신은 호스팅/인프라 서비스의 기술 문제 해결 전문가입니다.
고객의 문제를 체계적으로 진단하고 해결책을 제시합니다.
## 문제 해결 프로세스
1. **상태 파악**: 현재 상태 확인 (서버 상태, 도메인 상태, 서비스 상태)
2. **원인 분석**: 수집된 정보를 기반으로 근본 원인 분석
3. **해결책 제시**: 단계별 해결 방법 안내
4. **예측**: 재발 방지 및 모니터링 권고
## 정보 수집 항목
- **카테고리**: 서버, 도메인, DDoS, VPN, 네트워크, 기타
- **증상**: 구체적 증상 (접속 불가, 느림, 오류 메시지 등)
- **환경**: OS, 브라우저, 리전, 사용 중인 서비스
- **에러 메시지**: 정확한 에러 메시지 또는 코드
## 현재 세션 상태: ${session.status}
## 에스컬레이션 카운트: ${session.escalation_count || 0}
## 대화 원칙
- 항상 한국어로 응답하세요.
- 전문적이지만 이해하기 쉽게 설명하세요.
- 문제와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 상담이 완료되면 __SESSION_END__를 응답 끝에 추가하세요.
- 3라운드 이내에 해결이 어려운 경우 __ESCALATE__를 응답에 포함하세요.
이 경우 고객에게 "전문 엔지니어에게 전달하겠습니다"라고 안내하세요.
## 도구 사용
- check_server_status: 서버 상태 확인
- check_domain_status: 도메인 상태 확인
- check_service_status: DDoS/VPN 서비스 상태 확인`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'check_server_status',
description: '고객의 서버 상태를 확인합니다. 서버 ID 또는 전체 목록을 조회할 수 있습니다.',
parameters: {
type: 'object',
properties: {
server_id: {
type: 'number',
description: '특정 서버 ID (생략 시 전체 목록)',
},
},
},
},
},
{
type: 'function',
function: {
name: 'check_domain_status',
description: '고객의 도메인 상태를 확인합니다. 도메인명 또는 전체 목록을 조회할 수 있습니다.',
parameters: {
type: 'object',
properties: {
domain: {
type: 'string',
description: '특정 도메인명 (생략 시 전체 목록)',
},
},
},
},
},
{
type: 'function',
function: {
name: 'check_service_status',
description: '고객의 DDoS/VPN 서비스 상태를 확인합니다.',
parameters: {
type: 'object',
properties: {
service_type: {
type: 'string',
enum: ['ddos', 'vpn', 'all'],
description: '서비스 유형 (기본값: all)',
},
service_id: {
type: 'number',
description: '특정 서비스 ID (생략 시 전체 목록)',
},
},
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: TroubleshootSession,
context: AgentToolContext
): Promise<string> {
const { userId, db } = context;
switch (name) {
case 'check_server_status':
return this.handleCheckServerStatus(userId, args.server_id as number | undefined, db);
case 'check_domain_status':
return this.handleCheckDomainStatus(userId, args.domain as string | undefined, db);
case 'check_service_status':
return this.handleCheckServiceStatus(
userId,
(args.service_type as string) || 'all',
args.service_id as number | undefined,
db
);
default:
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
}
protected onBeforeSave(session: TroubleshootSession, response: string, _calledTools: string[]): void {
// Handle escalation marker
if (response.includes('__ESCALATE__')) {
session.status = 'escalated';
session.escalation_count = (session.escalation_count || 0) + 1;
logger.info('에스컬레이션 발생', {
userId: session.user_id,
escalationCount: session.escalation_count,
});
}
}
private async handleCheckServerStatus(
userId: string,
serverId: number | undefined,
db: D1Database
): Promise<string> {
try {
if (serverId) {
const server = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price, provider
FROM servers WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND id = ?`
).bind(userId, serverId).first();
if (!server) {
return JSON.stringify({ error: '해당 서버를 찾을 수 없습니다.' });
}
return JSON.stringify({ server });
}
const servers = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price
FROM servers WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
servers: servers.results || [],
count: servers.results?.length || 0,
});
} catch (error) {
logger.error('서버 상태 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '서버 상태 조회에 실패했습니다.' });
}
}
private async handleCheckDomainStatus(
userId: string,
domain: string | undefined,
db: D1Database
): Promise<string> {
try {
if (domain) {
const result = await db.prepare(
`SELECT id, domain, status, registrar, nameservers, auto_renew, expiry_date
FROM domains WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND domain = ?`
).bind(userId, domain).first();
if (!result) {
return JSON.stringify({ error: '해당 도메인을 찾을 수 없습니다.' });
}
return JSON.stringify({ domain: result });
}
const domains = await db.prepare(
`SELECT id, domain, status, expiry_date
FROM domains WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
domains: domains.results || [],
count: domains.results?.length || 0,
});
} catch (error) {
logger.error('도메인 상태 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '도메인 상태 조회에 실패했습니다.' });
}
}
private async handleCheckServiceStatus(
userId: string,
serviceType: string,
serviceId: number | undefined,
db: D1Database
): Promise<string> {
try {
const result: Record<string, unknown> = {};
if (serviceType === 'ddos' || serviceType === 'all') {
if (serviceId && serviceType === 'ddos') {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, provider, monthly_price, expiry_date
FROM services_ddos WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND id = ?`
).bind(userId, serviceId).first();
result.ddos = ddos || { error: '해당 DDoS 서비스를 찾을 수 없습니다.' };
} else {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, monthly_price, expiry_date
FROM services_ddos WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)`
).bind(userId).all();
result.ddos_services = ddos.results || [];
}
}
if (serviceType === 'vpn' || serviceType === 'all') {
if (serviceId && serviceType === 'vpn') {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND id = ?`
).bind(userId, serviceId).first();
result.vpn = vpn || { error: '해당 VPN 서비스를 찾을 수 없습니다.' };
} else {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)`
).bind(userId).all();
result.vpn_services = vpn.results || [];
}
}
return JSON.stringify(result);
} catch (error) {
logger.error('서비스 상태 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '서비스 상태 조회에 실패했습니다.' });
}
}
}

View File

@@ -0,0 +1,56 @@
// ============================================
// Centralized Agent Configuration
// ============================================
export type AgentType = 'onboarding' | 'troubleshoot' | 'asset' | 'billing';
export const SESSION_TTL: Record<AgentType, number> = {
onboarding: 60 * 60 * 1000, // 1 hour
troubleshoot: 60 * 60 * 1000, // 1 hour
asset: 30 * 60 * 1000, // 30 minutes
billing: 30 * 60 * 1000, // 30 minutes
};
export const MAX_SESSION_MESSAGES: Record<AgentType, number> = {
onboarding: 20,
troubleshoot: 20,
asset: 15,
billing: 10,
};
export const AI_CONFIG = {
model: 'gpt-4o-mini' as const,
maxToolCalls: 3,
defaultTemperature: 0.7,
agents: {
onboarding: { maxTokens: 1024, temperature: 0.8 },
troubleshoot: { maxTokens: 1024, temperature: 0.5 },
asset: { maxTokens: 512, temperature: 0.3 },
billing: { maxTokens: 512, temperature: 0.3 },
} satisfies Record<AgentType, { maxTokens: number; temperature: number }>,
};
export const SESSION_TABLES: Record<AgentType, string> = {
onboarding: 'onboarding_sessions',
troubleshoot: 'troubleshoot_sessions',
asset: 'asset_sessions',
billing: 'billing_sessions',
};
export interface SessionConfig {
ttl: number;
maxMessages: number;
maxTokens: number;
temperature: number;
tableName: string;
}
export function getSessionConfig(agentType: AgentType): SessionConfig {
return {
ttl: SESSION_TTL[agentType],
maxMessages: MAX_SESSION_MESSAGES[agentType],
maxTokens: AI_CONFIG.agents[agentType].maxTokens,
temperature: AI_CONFIG.agents[agentType].temperature,
tableName: SESSION_TABLES[agentType],
};
}

20
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,20 @@
const en: Record<string, string> = {
greeting: 'Hello! This is the AI customer support system. How can I help you?',
greeting_new: 'Welcome! Feel free to ask anything about our services.',
error_general: 'Sorry, an error occurred while processing your request. Please try again later.',
error_rate_limit: 'Too many requests. Please try again later.',
error_blocked: 'Your account has been restricted.',
error_ai_unavailable: 'AI service is temporarily unstable. Please try again later.',
feedback_prompt: 'Was this consultation helpful?',
feedback_thanks: 'Thank you for your feedback!',
session_end: 'The consultation has ended. Feel free to reach out anytime for further inquiries.',
escalation_notice: 'Connecting you to a representative. Please wait a moment.',
escalation_admin: 'An escalation request has been received.',
action_pending: 'Your request has been submitted. Please wait for admin approval.',
action_approved: 'Your request has been approved and executed.',
action_rejected: 'Your request has been rejected.',
admin_only: 'This feature is available to administrators only.',
typing: 'Preparing a response...',
};
export default en;

33
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import ko from './ko';
import en from './en';
export type SupportedLanguage = 'ko' | 'en' | 'cn' | 'jp';
const messages: Record<string, Record<string, string>> = {
ko,
en,
// cn and jp fall back to ko until translations are added
};
const DEFAULT_LANGUAGE: SupportedLanguage = 'ko';
/**
* Get a localized message by key
* Falls back to Korean if key not found in requested language
*/
export function getMessage(
lang: SupportedLanguage | string,
key: string,
params?: Record<string, string | number>
): string {
const langMessages = messages[lang];
let text = langMessages?.[key] ?? messages[DEFAULT_LANGUAGE]?.[key] ?? key;
if (params) {
for (const [param, value] of Object.entries(params)) {
text = text.replace(`{${param}}`, String(value));
}
}
return text;
}

20
src/i18n/ko.ts Normal file
View File

@@ -0,0 +1,20 @@
const ko: Record<string, string> = {
greeting: '안녕하세요! AI 고객 지원 시스템입니다. 무엇을 도와드릴까요?',
greeting_new: '환영합니다! 저희 서비스에 대해 궁금한 점이 있으시면 무엇이든 물어보세요.',
error_general: '죄송합니다, 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
error_rate_limit: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.',
error_blocked: '이용이 제한된 계정입니다.',
error_ai_unavailable: 'AI 서비스가 일시적으로 불안정합니다. 잠시 후 다시 시도해주세요.',
feedback_prompt: '상담이 도움이 되셨나요?',
feedback_thanks: '피드백 감사합니다!',
session_end: '상담이 종료되었습니다. 추가 문의가 있으시면 언제든 말씀해주세요.',
escalation_notice: '담당자에게 연결 중입니다. 잠시만 기다려주세요.',
escalation_admin: '에스컬레이션 요청이 접수되었습니다.',
action_pending: '요청이 접수되었습니다. 관리자 승인을 기다려주세요.',
action_approved: '요청이 승인되어 실행되었습니다.',
action_rejected: '요청이 거부되었습니다.',
admin_only: '관리자만 사용할 수 있는 기능입니다.',
typing: '답변을 준비 중입니다...',
};
export default ko;

155
src/index.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Hono } from 'hono';
import type { Env } from './types';
import { webhookRouter } from './routes/webhook';
import { apiRouter } from './routes/api';
import { healthRouter } from './routes/health';
import { setWebhook, getWebhookInfo } from './telegram';
import { timingSafeEqual } from './security';
import { validateEnv } from './utils/env-validation';
import { createLogger } from './utils/logger';
const logger = createLogger('worker');
let envValidated = false;
const app = new Hono<{ Bindings: Env }>();
// Environment validation middleware (runs once per worker instance)
app.use('*', async (c, next) => {
if (!envValidated) {
const result = validateEnv(c.env as unknown as Record<string, unknown>);
if (!result.success) {
logger.error('Environment validation failed', new Error('Invalid configuration'), {
errors: result.errors,
});
return c.json({
error: 'Configuration error',
message: 'The worker is not properly configured.',
}, 500);
}
if (result.warnings.length > 0) {
logger.warn('Environment configuration warnings', { warnings: result.warnings });
}
logger.info('Environment validation passed', {
environment: c.env.ENVIRONMENT || 'production',
warnings: result.warnings.length,
});
envValidated = true;
}
return await next();
});
// Health check
app.route('/health', healthRouter);
// Setup webhook
app.get('/setup-webhook', async (c) => {
const env = c.env;
if (!env.BOT_TOKEN || !env.WEBHOOK_SECRET) {
return c.json({ error: 'Server configuration error' }, 500);
}
const token = c.req.query('token');
const secret = c.req.query('secret');
if (!token || !timingSafeEqual(token, env.BOT_TOKEN)) {
return c.text('Unauthorized', 401);
}
if (!secret || !timingSafeEqual(secret, env.WEBHOOK_SECRET)) {
return c.text('Unauthorized', 401);
}
const webhookUrl = `${new URL(c.req.url).origin}/webhook`;
const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET);
return c.json(result);
});
// Webhook info
app.get('/webhook-info', async (c) => {
const env = c.env;
if (!env.BOT_TOKEN || !env.WEBHOOK_SECRET) {
return c.json({ error: 'Server configuration error' }, 500);
}
const token = c.req.query('token');
const secret = c.req.query('secret');
if (!token || !timingSafeEqual(token, env.BOT_TOKEN)) {
return c.text('Unauthorized', 401);
}
if (!secret || !timingSafeEqual(secret, env.WEBHOOK_SECRET)) {
return c.text('Unauthorized', 401);
}
const result = await getWebhookInfo(env.BOT_TOKEN);
return c.json(result);
});
// API routes
app.route('/api', apiRouter);
// Telegram Webhook
app.route('/webhook', webhookRouter);
// Root
app.get('/', (c) => {
return c.text(
`Telegram AI Support Bot
Endpoints:
GET /health - Health check
GET /webhook-info - Webhook status
GET /setup-webhook - Configure webhook
POST /webhook - Telegram webhook (authenticated)
GET /api/* - Admin API (authenticated)`,
200
);
});
// 404
app.notFound((c) => c.text('Not Found', 404));
export default {
fetch: app.fetch,
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
const cronSchedule = event.cron;
logger.info('Cron job started', { schedule: cronSchedule });
const {
cleanupExpiredSessions,
sendExpiryNotifications,
archiveOldConversations,
cleanupStaleOrders,
monitoringCheck,
} = await import('./services/cron-jobs');
try {
switch (cronSchedule) {
// Midnight KST (15:00 UTC): expiry notifications, archiving, session cleanup
case '0 15 * * *':
await sendExpiryNotifications(env);
await archiveOldConversations(env);
await cleanupExpiredSessions(env);
break;
// Every 5 minutes: stale session/order cleanup
case '*/5 * * * *':
await cleanupStaleOrders(env);
break;
// Every hour: monitoring checks
case '0 * * * *':
await monitoringCheck(env);
break;
default:
logger.warn('Unknown cron schedule', { schedule: cronSchedule });
}
} catch (error) {
logger.error('Cron job failed', error as Error, { schedule: cronSchedule });
}
},
};

174
src/routes/api.ts Normal file
View File

@@ -0,0 +1,174 @@
import { Hono } from 'hono';
import type { Env, User, Transaction } from '../types';
import { timingSafeEqual } from '../security';
import { getPendingActions } from '../services/pending-actions';
import { sendMessage } from '../telegram';
import { createLogger } from '../utils/logger';
const logger = createLogger('api');
const api = new Hono<{ Bindings: Env }>();
// Admin API auth middleware
api.use('*', async (c, next) => {
// Support both Bearer token and query param auth
const authHeader = c.req.header('Authorization');
const queryToken = c.req.query('token');
let token: string | undefined;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7);
} else if (queryToken) {
token = queryToken;
}
if (!token || !timingSafeEqual(token, c.env.WEBHOOK_SECRET)) {
return c.json({ error: 'Unauthorized' }, 401);
}
return next();
});
// GET /api/stats - Service statistics
api.get('/stats', async (c) => {
try {
const db = c.env.DB;
const [users, txPending, servers, feedback] = await Promise.all([
db.prepare('SELECT COUNT(*) as count FROM users').first<{ count: number }>(),
db.prepare("SELECT COUNT(*) as count FROM transactions WHERE status = 'pending'").first<{ count: number }>(),
db.prepare("SELECT COUNT(*) as count FROM servers WHERE status != 'terminated'").first<{ count: number }>(),
db.prepare('SELECT AVG(rating) as avg, COUNT(*) as count FROM feedback').first<{ avg: number | null; count: number }>(),
]);
return c.json({
users: users?.count ?? 0,
pendingTransactions: txPending?.count ?? 0,
activeServers: servers?.count ?? 0,
feedback: {
avgRating: feedback?.avg ? Number(feedback.avg.toFixed(2)) : 0,
count: feedback?.count ?? 0,
},
});
} catch (error) {
logger.error('Stats query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/users - List users (paginated)
api.get('/users', async (c) => {
try {
const limit = Math.min(parseInt(c.req.query('limit') ?? '20'), 100);
const offset = parseInt(c.req.query('offset') ?? '0');
const result = await c.env.DB.prepare(
`SELECT id, telegram_id, username, first_name, role, is_blocked, last_active_at, created_at
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?`
)
.bind(limit, offset)
.all<Pick<User, 'id' | 'telegram_id' | 'username' | 'first_name' | 'role' | 'is_blocked' | 'last_active_at' | 'created_at'>>();
return c.json({ users: result.results, count: result.results.length });
} catch (error) {
logger.error('Users query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/transactions/pending - Pending transactions
api.get('/transactions/pending', async (c) => {
try {
const result = await c.env.DB.prepare(
`SELECT t.id, t.user_id, t.amount, t.depositor_name, t.status, t.created_at,
u.username, u.telegram_id
FROM transactions t
JOIN users u ON t.user_id = u.id
WHERE t.status = 'pending' AND t.type = 'deposit'
ORDER BY t.created_at DESC LIMIT 50`
)
.all<Pick<Transaction, 'id' | 'user_id' | 'amount' | 'depositor_name' | 'status' | 'created_at'> & {
username: string | null; telegram_id: string;
}>();
return c.json({ transactions: result.results });
} catch (error) {
logger.error('Pending transactions query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/audit-logs - Audit logs
api.get('/audit-logs', async (c) => {
try {
const limit = Math.min(parseInt(c.req.query('limit') ?? '50'), 200);
const result = await c.env.DB.prepare(
`SELECT id, actor_id, action, resource_type, resource_id, result, created_at
FROM audit_logs ORDER BY created_at DESC LIMIT ?`
)
.bind(limit)
.all();
return c.json({ logs: result.results });
} catch (error) {
logger.error('Audit logs query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/pending-actions - Pending actions list
api.get('/pending-actions', async (c) => {
try {
const status = c.req.query('status');
const validStatuses = ['pending', 'approved', 'rejected', 'executed', 'failed'] as const;
const filterStatus = status && validStatuses.includes(status as typeof validStatuses[number])
? status as typeof validStatuses[number]
: undefined;
const actions = await getPendingActions(c.env.DB, filterStatus);
return c.json({ actions, count: actions.length });
} catch (error) {
logger.error('Pending actions query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// POST /api/broadcast - Send message to all users
api.post('/broadcast', async (c) => {
try {
const body = await c.req.json<{ message?: string }>();
if (!body.message || body.message.trim().length === 0) {
return c.json({ error: 'Message is required' }, 400);
}
if (body.message.length > 4000) {
return c.json({ error: 'Message too long (max 4000 chars)' }, 400);
}
const users = await c.env.DB
.prepare('SELECT telegram_id FROM users WHERE is_blocked = 0')
.all<{ telegram_id: string }>();
const telegramIds = users.results ?? [];
let sent = 0;
let failed = 0;
for (const user of telegramIds) {
try {
await sendMessage(c.env.BOT_TOKEN, parseInt(user.telegram_id), body.message);
sent++;
} catch {
failed++;
}
}
logger.info('Broadcast completed', { sent, failed, total: telegramIds.length });
return c.json({ sent, failed, total: telegramIds.length });
} catch (error) {
logger.error('Broadcast failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
export { api as apiRouter };

View File

@@ -0,0 +1,242 @@
import {
answerCallbackQuery,
editMessageText,
sendMessage,
} from '../../telegram';
import { approvePendingAction, rejectPendingAction } from '../../services/pending-actions';
import { createFeedback } from '../../services/feedback';
import { createAuditLog } from '../../services/audit';
import { isAdmin } from '../../security';
import { createLogger } from '../../utils/logger';
import type { Env, CallbackQuery } from '../../types';
const logger = createLogger('callback-handler');
export async function handleCallbackQuery(
env: Env,
callbackQuery: CallbackQuery
): Promise<void> {
const { id: queryId, from, message, data } = callbackQuery;
if (!data || !message) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
return;
}
const chatId = message.chat.id;
const messageId = message.message_id;
const telegramUserId = from.id.toString();
try {
// Feedback: fb:{session_type}:{rating}
if (data.startsWith('fb:')) {
await handleFeedback(env, queryId, chatId, messageId, telegramUserId, data);
return;
}
// Action approval: act:{action_id}:{approve|reject}
if (data.startsWith('act:')) {
await handleActionApproval(env, queryId, chatId, messageId, telegramUserId, data);
return;
}
// Escalation: esc:{session_id}:{accept|reject}
if (data.startsWith('esc:')) {
await handleEscalation(env, queryId, chatId, messageId, telegramUserId, data);
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId);
} catch (error) {
logger.error('Callback handling error', error as Error, { data, telegramUserId });
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '처리 중 오류가 발생했습니다.' });
}
}
async function handleFeedback(
env: Env,
queryId: string,
chatId: number,
messageId: number,
telegramUserId: string,
data: string
): Promise<void> {
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const sessionType = parts[1];
const rating = parseInt(parts[2], 10);
if (isNaN(rating) || rating < 1 || rating > 5) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 평점입니다.' });
return;
}
const user = await env.DB
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(telegramUserId)
.first<{ id: number }>();
if (!user) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
return;
}
await createFeedback(env.DB, {
userId: user.id,
sessionType,
rating,
});
const stars = '⭐'.repeat(rating);
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '피드백 감사합니다!' });
await editMessageText(env.BOT_TOKEN, chatId, messageId, `피드백이 등록되었습니다. ${stars}\n감사합니다!`);
}
async function handleActionApproval(
env: Env,
queryId: string,
chatId: number,
messageId: number,
telegramUserId: string,
data: string
): Promise<void> {
// Only admins can approve/reject actions
if (!isAdmin(telegramUserId, env.ADMIN_TELEGRAM_IDS)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '관리자만 사용할 수 있습니다.' });
return;
}
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const actionId = parseInt(parts[1], 10);
const approve = parts[2] === 'approve';
if (isNaN(actionId)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 작업 ID입니다.' });
return;
}
const admin = await env.DB
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(telegramUserId)
.first<{ id: number }>();
if (!admin) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '관리자 정보를 찾을 수 없습니다.' });
return;
}
if (approve) {
const result = await approvePendingAction(env.DB, actionId, admin.id);
if (!result) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '이미 처리된 요청입니다.' });
return;
}
await createAuditLog(env.DB, {
actorId: admin.id,
action: 'approve_action',
resourceType: 'pending_action',
resourceId: String(actionId),
result: 'success',
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '승인되었습니다.' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`✅ 작업 #${actionId} 승인 완료\n유형: ${result.action_type}\n대상: ${result.target}`
);
// Notify user about the approval
if (result.user_id) {
const actionUser = await env.DB
.prepare('SELECT telegram_id FROM users WHERE id = ?')
.bind(result.user_id)
.first<{ telegram_id: string }>();
if (actionUser) {
await sendMessage(env.BOT_TOKEN, parseInt(actionUser.telegram_id), '요청이 승인되어 실행되었습니다.').catch(() => {});
}
}
} else {
const result = await rejectPendingAction(env.DB, actionId, admin.id);
if (!result) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '이미 처리된 요청입니다.' });
return;
}
await createAuditLog(env.DB, {
actorId: admin.id,
action: 'reject_action',
resourceType: 'pending_action',
resourceId: String(actionId),
result: 'success',
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '거부되었습니다.' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`❌ 작업 #${actionId} 거부\n유형: ${result.action_type}\n대상: ${result.target}`
);
// Notify user about the rejection
if (result.user_id) {
const actionUser = await env.DB
.prepare('SELECT telegram_id FROM users WHERE id = ?')
.bind(result.user_id)
.first<{ telegram_id: string }>();
if (actionUser) {
await sendMessage(env.BOT_TOKEN, parseInt(actionUser.telegram_id), '요청이 거부되었습니다.').catch(() => {});
}
}
}
}
async function handleEscalation(
env: Env,
queryId: string,
chatId: number,
messageId: number,
telegramUserId: string,
data: string
): Promise<void> {
if (!isAdmin(telegramUserId, env.ADMIN_TELEGRAM_IDS)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '관리자만 사용할 수 있습니다.' });
return;
}
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const sessionId = parts[1];
const action = parts[2];
if (action === 'accept') {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '에스컬레이션 수락' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`✅ 에스컬레이션 수락됨\n세션: ${sessionId}\n담당: 관리자`
);
// Notify the user their escalation was accepted
await sendMessage(
env.BOT_TOKEN,
parseInt(sessionId),
'관리자가 문의를 확인했습니다. 잠시만 기다려주세요.'
).catch(() => {});
} else {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '에스컬레이션 거부' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`❌ 에스컬레이션 거부됨\n세션: ${sessionId}`
);
}
}

View File

@@ -0,0 +1,371 @@
/**
* Message Handler - 텔레그램 메시지 처리의 핵심 모듈
*
* 메시지 수신 -> 사용자 등록/확인 -> 에이전트 라우팅 -> AI 응답 -> 피드백 수집
*/
import { sendMessage, sendMessageWithKeyboard, sendChatAction } from '../../telegram';
import { checkRateLimit } from '../../security';
import { registerAgent, routeToActiveAgent } from '../../agents/agent-registry';
import { OnboardingAgent } from '../../agents/onboarding-agent';
import { TroubleshootAgent } from '../../agents/troubleshoot-agent';
import { AssetAgent } from '../../agents/asset-agent';
import { BillingAgent } from '../../agents/billing-agent';
import { getMessage } from '../../i18n';
import {
ONBOARDING_PATTERNS,
TROUBLESHOOT_PATTERNS,
ASSET_PATTERNS,
BILLING_PATTERNS,
} from '../../utils/patterns';
import { selectToolsForMessage, executeTool } from '../../tools';
import { createLogger } from '../../utils/logger';
import { getOpenAIUrl } from '../../utils/api-urls';
import { AI_CONFIG } from '../../constants/agent-config';
import { escalateToAdmin, shouldEscalate } from '../../services/human-handoff';
import type { Env, TelegramUpdate, User, OpenAIAPIResponse, OpenAIMessage } from '../../types';
const logger = createLogger('message-handler');
// Register agent singletons with priorities (lower = checked first)
const onboardingAgent = new OnboardingAgent();
const troubleshootAgent = new TroubleshootAgent();
const assetAgent = new AssetAgent();
const billingAgent = new BillingAgent();
registerAgent('onboarding', onboardingAgent, 10);
registerAgent('troubleshoot', troubleshootAgent, 20);
registerAgent('asset', assetAgent, 30);
registerAgent('billing', billingAgent, 40);
export async function handleMessage(
env: Env,
update: TelegramUpdate
): Promise<void> {
if (!update.message?.text) return;
const message = update.message;
const chatId = message.chat.id;
const text = message.text!;
const telegramUserId = message.from.id.toString();
const lang = message.from.language_code ?? 'ko';
const requestId = crypto.randomUUID();
// 1. Check if user is blocked & register/update user
const user = await getOrCreateUser(env.DB, telegramUserId, message.from.first_name ?? '', message.from.username);
if (!user) {
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_general'));
return;
}
if (user.is_blocked) {
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_blocked'));
return;
}
// 2. Rate limiting
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_rate_limit'));
return;
}
// 3. Send typing action
await sendChatAction(env.BOT_TOKEN, chatId).catch(() => {});
try {
// 4. Route to active agent session first
const agentResponse = await routeToActiveAgent(env.DB, telegramUserId, text, env);
if (agentResponse) {
const { cleanText, sessionEnded } = cleanSessionMarkers(agentResponse);
// Handle escalation marker
if (agentResponse.includes('__ESCALATE__')) {
const cleaned = cleanText.replace('__ESCALATE__', '').trim();
await storeConversation(env.DB, user.id, text, cleaned, requestId);
await sendMessage(env.BOT_TOKEN, chatId, cleaned);
await escalateToAdmin(env, telegramUserId, text, 'agent');
return;
}
await storeConversation(env.DB, user.id, text, cleanText, requestId);
await sendMessage(env.BOT_TOKEN, chatId, cleanText);
// Prompt feedback after session end
if (sessionEnded) {
await promptFeedback(env, chatId, lang, 'agent');
}
return;
}
// 5. Detect intent for new session creation
let response: string | null = null;
if (ONBOARDING_PATTERNS.test(text)) {
response = await onboardingAgent.processConsultation(env.DB, telegramUserId, text, env);
} else if (TROUBLESHOOT_PATTERNS.test(text)) {
response = await troubleshootAgent.processConsultation(env.DB, telegramUserId, text, env);
} else if (BILLING_PATTERNS.test(text)) {
response = await billingAgent.processConsultation(env.DB, telegramUserId, text, env);
} else if (ASSET_PATTERNS.test(text)) {
response = await assetAgent.processConsultation(env.DB, telegramUserId, text, env);
}
if (response) {
const { cleanText, sessionEnded } = cleanSessionMarkers(response);
await storeConversation(env.DB, user.id, text, cleanText, requestId);
await sendMessage(env.BOT_TOKEN, chatId, cleanText);
if (sessionEnded) {
await promptFeedback(env, chatId, lang, 'agent');
}
return;
}
// 6. General AI fallback (no matching agent)
const aiResponse = await handleGeneralAI(env, user, text, telegramUserId);
await storeConversation(env.DB, user.id, text, aiResponse, requestId);
await sendMessage(env.BOT_TOKEN, chatId, aiResponse);
// 7. Check frustration for potential escalation
if (shouldEscalate(text, 0)) {
logger.warn('Frustration detected in general AI flow', { telegramUserId });
}
} catch (error) {
logger.error('Message handling error', error as Error, { telegramUserId, requestId });
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_general'));
}
}
function cleanSessionMarkers(text: string): { cleanText: string; sessionEnded: boolean } {
const sessionEnded = text.includes('[세션 종료]') || text.includes('__SESSION_END__');
const cleanText = text
.replace('[세션 종료]', '')
.replace('__SESSION_END__', '')
.trim();
return { cleanText, sessionEnded };
}
async function getOrCreateUser(
db: D1Database,
telegramId: string,
firstName: string,
username?: string
): Promise<(User & { id: number }) | null> {
try {
await db
.prepare(
`INSERT INTO users (telegram_id, username, first_name, last_active_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (telegram_id) DO UPDATE SET
username = COALESCE(excluded.username, users.username),
first_name = COALESCE(excluded.first_name, users.first_name),
last_active_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`
)
.bind(telegramId, username ?? null, firstName)
.run();
const user = await db
.prepare('SELECT * FROM users WHERE telegram_id = ?')
.bind(telegramId)
.first<User>();
return user ?? null;
} catch (error) {
logger.error('User upsert failed', error as Error, { telegramId });
return null;
}
}
async function handleGeneralAI(
env: Env,
user: User,
userMessage: string,
telegramUserId: string
): Promise<string> {
if (!env.OPENAI_API_KEY) {
return await handleWorkersAIFallback(env, userMessage);
}
try {
// Load recent conversation context
const history = await env.DB
.prepare(
`SELECT role, content FROM conversations
WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
)
.bind(user.id, user.context_limit)
.all<{ role: string; content: string }>();
const conversationHistory = history.results.reverse();
const tools = selectToolsForMessage(userMessage);
const messages: OpenAIMessage[] = [
{
role: 'system',
content: `당신은 클라우드 호스팅/도메인/서버 관리 서비스의 AI 고객 지원 상담사입니다.
한국어로 친절하고 전문적으로 답변하세요.
기술 용어는 쉽게 풀어 설명하세요.
답변을 모르면 솔직히 모른다고 하고, 관리자 연결을 제안하세요.`,
},
...conversationHistory.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user', content: userMessage },
];
const body: Record<string, unknown> = {
model: AI_CONFIG.model,
messages,
max_tokens: 1024,
temperature: AI_CONFIG.defaultTemperature,
};
if (tools.length > 0) {
body.tools = tools;
body.tool_choice = 'auto';
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000);
try {
const response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify(body),
});
if (!response.ok) {
logger.error('OpenAI API error', new Error(`HTTP ${response.status}`));
return await handleWorkersAIFallback(env, userMessage);
}
const data = (await response.json()) as OpenAIAPIResponse;
const assistantMessage = data.choices[0].message;
// Handle tool calls (single round for general AI)
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
// Add assistant message with tool calls
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
});
for (const tc of assistantMessage.tool_calls) {
let args: Record<string, unknown>;
try {
args = JSON.parse(tc.function.arguments) as Record<string, unknown>;
} catch {
continue;
}
const result = await executeTool(tc.function.name, args, env, telegramUserId, env.DB);
messages.push({
role: 'tool',
content: result,
tool_call_id: tc.id,
name: tc.function.name,
});
}
// Second call to synthesize tool results
const followUp = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: AI_CONFIG.model,
messages,
max_tokens: 1024,
temperature: AI_CONFIG.defaultTemperature,
}),
});
if (followUp.ok) {
const followUpData = (await followUp.json()) as OpenAIAPIResponse;
return followUpData.choices[0].message.content ?? getMessage('ko', 'error_ai_unavailable');
}
// Fallback: return AI text
return assistantMessage.content ?? getMessage('ko', 'error_ai_unavailable');
}
return assistantMessage.content ?? getMessage('ko', 'error_ai_unavailable');
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
logger.error('General AI error', error as Error);
return await handleWorkersAIFallback(env, userMessage);
}
}
async function handleWorkersAIFallback(env: Env, userMessage: string): Promise<string> {
try {
const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct-fp8', {
messages: [
{
role: 'system',
content: '당신은 클라우드 호스팅 서비스의 AI 고객 지원 상담사입니다. 한국어로 친절하게 답변하세요.',
},
{ role: 'user', content: userMessage },
],
});
if (result && typeof result === 'object' && 'response' in result) {
return (result as { response: string }).response;
}
return getMessage('ko', 'error_ai_unavailable');
} catch (error) {
logger.error('Workers AI fallback error', error as Error);
return getMessage('ko', 'error_ai_unavailable');
}
}
async function storeConversation(
db: D1Database,
userId: number,
userMessage: string,
assistantResponse: string,
requestId: string
): Promise<void> {
try {
await db.batch([
db.prepare(
`INSERT INTO conversations (user_id, role, content, request_id) VALUES (?, 'user', ?, ?)`
).bind(userId, userMessage, requestId),
db.prepare(
`INSERT INTO conversations (user_id, role, content, request_id) VALUES (?, 'assistant', ?, ?)`
).bind(userId, assistantResponse, requestId),
]);
} catch (error) {
logger.error('Conversation storage failed', error as Error, { userId, requestId });
}
}
async function promptFeedback(
env: Env,
chatId: number,
lang: string,
sessionType: string
): Promise<void> {
try {
const text = getMessage(lang, 'feedback_prompt');
const keyboard = [
[1, 2, 3, 4, 5].map((rating) => ({
text: '⭐'.repeat(rating),
callback_data: `fb:${sessionType}:${rating}`,
})),
];
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, text, keyboard);
} catch (error) {
logger.error('Failed to send feedback prompt', error as Error);
}
}

13
src/routes/health.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Hono } from 'hono';
const health = new Hono();
health.get('/', (c) => {
return c.json({
status: 'ok',
service: 'telegram-ai-support',
timestamp: new Date().toISOString(),
});
});
export { health as healthRouter };

96
src/routes/webhook.ts Normal file
View File

@@ -0,0 +1,96 @@
import { Hono } from 'hono';
import { createMiddleware } from 'hono/factory';
import type { Env, TelegramUpdate } from '../types';
import { timingSafeEqual } from '../security';
import { handleCallbackQuery } from './handlers/callback-handler';
import { handleMessage } from './handlers/message-handler';
import { createLogger } from '../utils/logger';
const logger = createLogger('webhook');
const webhook = new Hono<{ Bindings: Env }>();
// Telegram webhook authentication middleware
const telegramAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
if (c.req.method !== 'POST') {
logger.warn('Invalid HTTP method', { method: c.req.method });
return c.text('Method not allowed', 405);
}
const contentType = c.req.header('Content-Type');
if (!contentType?.includes('application/json')) {
logger.warn('Invalid content type', { contentType });
return c.text('Invalid content type', 400);
}
const secretToken = c.req.header('X-Telegram-Bot-Api-Secret-Token');
if (!c.env.WEBHOOK_SECRET) {
logger.error('WEBHOOK_SECRET not configured', new Error('Missing WEBHOOK_SECRET'));
return c.text('Server configuration error', 500);
}
if (!timingSafeEqual(secretToken, c.env.WEBHOOK_SECRET)) {
logger.warn('Invalid webhook secret token');
return c.text('Unauthorized', 401);
}
const clientIP = c.req.header('CF-Connecting-IP');
if (clientIP) {
logger.debug('Request from IP', { clientIP });
}
return next();
});
webhook.post('/', telegramAuth, async (c) => {
let update: TelegramUpdate;
try {
update = await c.req.json<TelegramUpdate>();
} catch (error) {
logger.error('JSON parsing error', error as Error);
return c.json({ ok: true });
}
if (!update || typeof update.update_id !== 'number') {
logger.warn('Invalid update structure', { updateKeys: update ? Object.keys(update) : [] });
return c.json({ ok: true });
}
// Timestamp validation (5 minutes) - replay attack prevention
if (update.message?.date) {
const messageTime = update.message.date * 1000;
const now = Date.now();
const MAX_AGE_MS = 5 * 60 * 1000;
if (now - messageTime > MAX_AGE_MS) {
logger.warn('Message too old', { messageAge: Math.floor((now - messageTime) / 1000) });
return c.json({ ok: true });
}
}
try {
if (update.callback_query) {
await handleCallbackQuery(c.env, update.callback_query);
return c.json({ ok: true });
}
if (update.message) {
await handleMessage(c.env, update);
return c.json({ ok: true });
}
logger.debug('Unknown update type', { updateKeys: Object.keys(update) });
return c.json({ ok: true });
} catch (error) {
// Always return 200 to Telegram to prevent retries
logger.error('Webhook processing error', error as Error, {
updateId: update.update_id,
hasMessage: !!update.message,
hasCallback: !!update.callback_query,
});
return c.json({ ok: true });
}
});
export { webhook as webhookRouter };

161
src/security.ts Normal file
View File

@@ -0,0 +1,161 @@
import { Env, TelegramUpdate } from './types';
// Telegram server IP ranges (2024)
// https://core.telegram.org/bots/webhooks#the-short-version
const TELEGRAM_IP_RANGES = [
'149.154.160.0/20',
'91.108.4.0/22',
];
function ipInCIDR(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split('/');
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
const ipNum = ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
const rangeNum = range.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
return (ipNum & mask) === (rangeNum & mask);
}
function isValidTelegramIP(ip: string): boolean {
return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range));
}
/**
* Timing-safe string comparison to prevent timing attacks
*/
export function timingSafeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
if (!a || !b) return false;
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
function isValidSecretToken(request: Request, expectedSecret: string): boolean {
const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token');
return timingSafeEqual(secretHeader, expectedSecret);
}
function isValidRequestBody(body: unknown): body is TelegramUpdate {
return (
body !== null &&
typeof body === 'object' &&
'update_id' in body &&
typeof (body as TelegramUpdate).update_id === 'number'
);
}
// Reject messages older than 5 minutes (replay attack prevention)
function isRecentUpdate(message: TelegramUpdate['message']): boolean {
if (!message?.date) return true;
const messageTime = message.date * 1000;
const now = Date.now();
const MAX_AGE_MS = 5 * 60 * 1000;
return (now - messageTime) < MAX_AGE_MS;
}
export interface SecurityCheckResult {
valid: boolean;
error?: string;
update?: TelegramUpdate;
}
export async function validateWebhookRequest(
request: Request,
env: Env
): Promise<SecurityCheckResult> {
// 1. HTTP method
if (request.method !== 'POST') {
return { valid: false, error: 'Method not allowed' };
}
// 2. Content-Type
const contentType = request.headers.get('Content-Type');
if (!contentType?.includes('application/json')) {
return { valid: false, error: 'Invalid content type' };
}
// 3. Secret token (required)
if (!env.WEBHOOK_SECRET) {
console.error('[Security] WEBHOOK_SECRET not configured');
return { valid: false, error: 'Security configuration error' };
}
if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) {
console.warn('[Security] Invalid webhook secret token');
return { valid: false, error: 'Invalid secret token' };
}
// 4. IP whitelist (advisory - CF proxy may alter IP)
const clientIP = request.headers.get('CF-Connecting-IP');
if (clientIP && !isValidTelegramIP(clientIP)) {
console.warn('[Security] Request from non-Telegram IP:', clientIP);
}
// 5. Parse and validate body
let body: unknown;
try {
body = await request.json();
} catch {
return { valid: false, error: 'Invalid JSON body' };
}
if (!isValidRequestBody(body)) {
return { valid: false, error: 'Invalid request body structure' };
}
// 6. Timestamp check (replay attack prevention)
if (!isRecentUpdate(body.message)) {
return { valid: false, error: 'Message too old' };
}
return { valid: true, update: body };
}
/**
* Rate limiting using KV
* Returns true if request should be allowed
*/
export async function checkRateLimit(
kv: KVNamespace,
userId: string,
maxRequests: number = 30,
windowSeconds: number = 60
): Promise<boolean> {
const key = `rate:${userId}`;
const now = Math.floor(Date.now() / 1000);
try {
const raw = await kv.get(key, 'json') as { count: number; windowStart: number } | null;
if (!raw || now - raw.windowStart >= windowSeconds) {
await kv.put(key, JSON.stringify({ count: 1, windowStart: now }), { expirationTtl: windowSeconds });
return true;
}
if (raw.count >= maxRequests) {
return false;
}
await kv.put(key, JSON.stringify({ count: raw.count + 1, windowStart: raw.windowStart }), { expirationTtl: windowSeconds });
return true;
} catch (error) {
console.error('[Security] Rate limit check failed:', error);
return true; // Allow on error to avoid blocking legitimate requests
}
}
/**
* Check if a Telegram user ID is in the admin list
*/
export function isAdmin(telegramId: string | number, adminIds?: string): boolean {
if (!adminIds) return false;
const ids = adminIds.split(',').map(id => id.trim());
return ids.includes(String(telegramId));
}

48
src/services/audit.ts Normal file
View File

@@ -0,0 +1,48 @@
import { createLogger } from '../utils/logger';
const logger = createLogger('audit');
interface CreateAuditLogParams {
actorId: number | null;
action: string;
resourceType: string;
resourceId?: string | null;
details?: string | null;
result: 'success' | 'failure';
requestId?: string | null;
}
export async function createAuditLog(
db: D1Database,
params: CreateAuditLogParams
): Promise<void> {
try {
await db
.prepare(
`INSERT INTO audit_logs (actor_id, action, resource_type, resource_id, details, result, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(
params.actorId,
params.action,
params.resourceType,
params.resourceId ?? null,
params.details ?? null,
params.result,
params.requestId ?? null
)
.run();
logger.info('Audit log created', {
action: params.action,
resourceType: params.resourceType,
resourceId: params.resourceId,
result: params.result,
});
} catch (error) {
logger.error('Failed to create audit log', error as Error, {
action: params.action,
resourceType: params.resourceType,
});
}
}

300
src/services/cron-jobs.ts Normal file
View File

@@ -0,0 +1,300 @@
/**
* Cron Jobs - 스케줄 작업
*
* - cleanupExpiredSessions: 만료된 에이전트 세션 삭제
* - sendExpiryNotifications: 도메인/서버 만료 알림 (3일/1일)
* - archiveOldConversations: 90일 이상 대화 아카이빙
* - cleanupStaleOrders: 5분 이상 보류된 서버 주문 취소
* - monitoringCheck: 기본 헬스 체크 (DB 통계 로깅)
*/
import { createLogger } from '../utils/logger';
import { sendMessage } from '../telegram';
import { notifyAdmins } from './notification';
import type { Env } from '../types';
const logger = createLogger('cron-jobs');
const SESSION_TABLES = [
'onboarding_sessions',
'troubleshoot_sessions',
'asset_sessions',
'billing_sessions',
];
/**
* Delete expired rows from all agent session tables.
*/
export async function cleanupExpiredSessions(env: Env): Promise<void> {
const now = Date.now();
let totalDeleted = 0;
for (const table of SESSION_TABLES) {
try {
const result = await env.DB
.prepare(`DELETE FROM ${table} WHERE expires_at < ?`)
.bind(now)
.run();
const deleted = result.meta.changes ?? 0;
if (deleted > 0) {
totalDeleted += deleted;
logger.info('Expired sessions cleaned', { table, deleted });
}
} catch (error) {
logger.error(`Failed to clean ${table}`, error as Error);
}
}
if (totalDeleted > 0) {
logger.info('Total expired sessions cleaned', { totalDeleted });
}
}
/**
* Expiry notifications: domains/servers expiring within 3 days or 1 day.
*/
export async function sendExpiryNotifications(env: Env): Promise<void> {
try {
const db = env.DB;
// Domains expiring within 7 days (superset)
const expiringDomains = await db
.prepare(
`SELECT d.domain, d.expiry_date, u.telegram_id
FROM domains d
JOIN users u ON d.user_id = u.id
WHERE d.status = 'active'
AND d.expiry_date IS NOT NULL
AND d.expiry_date <= datetime('now', '+7 days')
AND d.expiry_date > datetime('now')
AND u.is_blocked = 0`
)
.all<{ domain: string; expiry_date: string; telegram_id: string }>();
for (const d of expiringDomains.results) {
const expiry = d.expiry_date.split('T')[0];
const daysLeft = Math.ceil(
(new Date(d.expiry_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const urgency = daysLeft <= 1 ? '🔴' : '⚠️';
await sendMessage(
env.BOT_TOKEN,
parseInt(d.telegram_id),
`${urgency} <b>도메인 만료 알림</b>\n\n` +
`도메인: <code>${d.domain}</code>\n` +
`만료일: ${expiry} (${daysLeft}일 남음)\n\n` +
`자동 갱신 설정을 확인해주세요.`
).catch((e) => logger.error('Domain expiry notification failed', e as Error));
}
// Servers expiring within 7 days
const expiringServers = await db
.prepare(
`SELECT s.label, s.id, s.ip_address, s.expires_at, u.telegram_id
FROM servers s
JOIN users u ON s.user_id = u.id
WHERE s.status = 'running'
AND s.expires_at IS NOT NULL
AND s.expires_at <= datetime('now', '+7 days')
AND s.expires_at > datetime('now')
AND u.is_blocked = 0`
)
.all<{ label: string | null; id: number; ip_address: string | null; expires_at: string; telegram_id: string }>();
for (const s of expiringServers.results) {
const name = s.label ?? s.ip_address ?? `서버 #${s.id}`;
const expiry = s.expires_at.split('T')[0];
const daysLeft = Math.ceil(
(new Date(s.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const urgency = daysLeft <= 1 ? '🔴' : '⚠️';
await sendMessage(
env.BOT_TOKEN,
parseInt(s.telegram_id),
`${urgency} <b>서버 만료 알림</b>\n\n` +
`서버: ${name}\n` +
`만료일: ${expiry} (${daysLeft}일 남음)\n\n` +
`연장이 필요하시면 문의해주세요.`
).catch((e) => logger.error('Server expiry notification failed', e as Error));
}
logger.info('Expiry notifications sent', {
domains: expiringDomains.results.length,
servers: expiringServers.results.length,
});
} catch (error) {
logger.error('Expiry notification job failed', error as Error);
}
}
/**
* Archive old conversations (90+ days).
* Creates summary archives and deletes original messages.
*/
export async function archiveOldConversations(env: Env): Promise<void> {
try {
const db = env.DB;
// Get users with old messages
const usersWithOldMessages = await db
.prepare(
`SELECT DISTINCT user_id, COUNT(*) as msg_count
FROM conversations
WHERE created_at < datetime('now', '-90 days')
GROUP BY user_id
LIMIT 50`
)
.all<{ user_id: number; msg_count: number }>();
let totalArchived = 0;
for (const u of usersWithOldMessages.results) {
// Get date range for old messages
const dateRange = await db
.prepare(
`SELECT MIN(created_at) as period_start, MAX(created_at) as period_end
FROM conversations
WHERE user_id = ? AND created_at < datetime('now', '-90 days')`
)
.bind(u.user_id)
.first<{ period_start: string; period_end: string }>();
if (!dateRange) continue;
// Create summary archive
await db
.prepare(
`INSERT INTO conversation_archives (user_id, summary, message_count, period_start, period_end)
VALUES (?, ?, ?, ?, ?)`
)
.bind(
u.user_id,
`${u.msg_count}개 메시지 아카이브`,
u.msg_count,
dateRange.period_start,
dateRange.period_end
)
.run();
// Delete archived messages
await db
.prepare(
`DELETE FROM conversations
WHERE user_id = ? AND created_at < datetime('now', '-90 days')`
)
.bind(u.user_id)
.run();
totalArchived += u.msg_count;
}
if (totalArchived > 0) {
logger.info('Conversation archiving completed', {
usersProcessed: usersWithOldMessages.results.length,
messagesArchived: totalArchived,
});
}
} catch (error) {
logger.error('Conversation archiving failed', error as Error);
}
}
/**
* Cancel pending server orders older than 5 minutes.
* Also cancels deposit transactions pending for more than 24 hours.
*/
export async function cleanupStaleOrders(env: Env): Promise<void> {
try {
const db = env.DB;
// Cancel stale server orders (5 min)
const staleServers = await db
.prepare(
`UPDATE servers SET status = 'failed', updated_at = CURRENT_TIMESTAMP
WHERE status = 'pending'
AND created_at < datetime('now', '-5 minutes')`
)
.run();
const serversCleaned = staleServers.meta.changes ?? 0;
// Cancel stale deposit transactions (24 hours)
const staleTx = await db
.prepare(
`UPDATE transactions
SET status = 'cancelled'
WHERE status = 'pending'
AND type = 'deposit'
AND created_at < datetime('now', '-24 hours')`
)
.run();
const txCleaned = staleTx.meta.changes ?? 0;
// Clean expired sessions too
const now = Date.now();
for (const table of SESSION_TABLES) {
await db.prepare(`DELETE FROM ${table} WHERE expires_at < ?`).bind(now).run();
}
if (serversCleaned > 0 || txCleaned > 0) {
logger.info('Stale cleanup completed', {
staleServers: serversCleaned,
cancelledTransactions: txCleaned,
});
}
} catch (error) {
logger.error('Stale cleanup failed', error as Error);
}
}
/**
* Basic health/monitoring check - log DB stats, alert on anomalies.
*/
export async function monitoringCheck(env: Env): Promise<void> {
try {
const db = env.DB;
// Check for high pending transaction count
const pendingCount = await db
.prepare("SELECT COUNT(*) as count FROM transactions WHERE status = 'pending'")
.first<{ count: number }>();
if (pendingCount && pendingCount.count > 20) {
await notifyAdmins(
env,
`⚠️ 모니터링 알림: 대기 중 거래가 ${pendingCount.count}건입니다. 확인이 필요합니다.`
);
}
// Check for pending actions older than 1 hour
const staleActions = await db
.prepare(
`SELECT COUNT(*) as count FROM pending_actions
WHERE status = 'pending' AND created_at < datetime('now', '-1 hours')`
)
.first<{ count: number }>();
if (staleActions && staleActions.count > 0) {
await notifyAdmins(
env,
`⚠️ 모니터링 알림: 1시간 이상 대기 중인 작업이 ${staleActions.count}건 있습니다.`
);
}
logger.info('Monitoring checks completed', {
pendingTx: pendingCount?.count ?? 0,
staleActions: staleActions?.count ?? 0,
});
} catch (error) {
logger.error('Monitoring checks failed', error as Error);
}
}
// Legacy aliases for backward compatibility
export { sendExpiryNotifications as notifyExpiringAssets };
export { cleanupStaleOrders as cleanupStalePending };
export { monitoringCheck as runMonitoringChecks };

73
src/services/feedback.ts Normal file
View File

@@ -0,0 +1,73 @@
import { createLogger } from '../utils/logger';
const logger = createLogger('feedback');
interface CreateFeedbackParams {
userId: number;
sessionType: string;
rating: number;
comment?: string;
}
interface FeedbackStats {
avgRating: number;
count: number;
}
export async function createFeedback(
db: D1Database,
params: CreateFeedbackParams
): Promise<void> {
try {
await db
.prepare(
`INSERT INTO feedback (user_id, session_type, rating, comment)
VALUES (?, ?, ?, ?)`
)
.bind(params.userId, params.sessionType, params.rating, params.comment ?? null)
.run();
logger.info('Feedback created', {
userId: params.userId,
sessionType: params.sessionType,
rating: params.rating,
});
} catch (error) {
logger.error('Failed to create feedback', error as Error, {
userId: params.userId,
});
throw error;
}
}
export async function getFeedbackStats(
db: D1Database,
sessionType?: string
): Promise<FeedbackStats> {
try {
let result;
if (sessionType) {
result = await db
.prepare(
`SELECT AVG(rating) as avg_rating, COUNT(*) as count
FROM feedback WHERE session_type = ?`
)
.bind(sessionType)
.first<{ avg_rating: number | null; count: number }>();
} else {
result = await db
.prepare(
`SELECT AVG(rating) as avg_rating, COUNT(*) as count FROM feedback`
)
.first<{ avg_rating: number | null; count: number }>();
}
return {
avgRating: result?.avg_rating ?? 0,
count: result?.count ?? 0,
};
} catch (error) {
logger.error('Failed to get feedback stats', error as Error);
return { avgRating: 0, count: 0 };
}
}

View File

@@ -0,0 +1,161 @@
/**
* Human Handoff Service - 사람 에스컬레이션 메커니즘
*
* AI가 처리할 수 없는 상황을 감지하고 관리자에게 전달합니다.
* - shouldEscalate: 단일 메시지 기반 에스컬레이션 판단
* - detectFrustration: 최근 메시지 배열 분석
* - escalateToAdmin: 관리자에게 에스컬레이션 알림 발송
* - handleAdminTakeover: 관리자가 사용자 대화를 인수
*/
import { createLogger } from '../utils/logger';
import { sendMessage, sendMessageWithKeyboard } from '../telegram';
import { notifyAdmins } from './notification';
import type { Env } from '../types';
const logger = createLogger('human-handoff');
// Frustration detection patterns
const FRUSTRATION_PATTERNS = /답답|화나|짜증|말이\s*안\s*통|쓸모.*없|다시\s*말해|아니\s*그게|못\s*알아|엉뚱|안\s*됐|계속\s*안|반복|다른\s*상담|사람.*연결|상담사|책임자|관리자|매니저/i;
const FRUSTRATION_PATTERNS_EN = /human|agent|operator|help me|useless|not working|frustrated|angry/i;
const REPEATED_FAILURE_THRESHOLD = 3;
/**
* Check if escalation to a human is needed (single message check)
*/
export function shouldEscalate(
userMessage: string,
escalationCount: number
): boolean {
if (FRUSTRATION_PATTERNS.test(userMessage)) return true;
if (FRUSTRATION_PATTERNS_EN.test(userMessage)) return true;
if (escalationCount >= REPEATED_FAILURE_THRESHOLD) return true;
return false;
}
/**
* Analyze recent messages for frustration signals.
* Returns true if escalation should be considered.
*/
export function detectFrustration(
messages: Array<{ role: string; content: string }>
): boolean {
const userMessages = messages.filter(m => m.role === 'user');
// Check each message for frustration patterns
for (const msg of userMessages) {
if (FRUSTRATION_PATTERNS.test(msg.content)) return true;
if (FRUSTRATION_PATTERNS_EN.test(msg.content)) return true;
}
// Detect repeated similar messages (3+ identical messages = frustration)
if (userMessages.length >= 3) {
const recent = userMessages.slice(-3);
const unique = new Set(recent.map(m => m.content.toLowerCase().trim()));
if (unique.size === 1) return true;
}
return false;
}
/**
* Escalate conversation to admin with inline keyboard
*/
export async function escalateToAdmin(
env: Env,
userId: string,
userMessage: string,
sessionType: string,
context?: string
): Promise<string> {
try {
const adminIds = env.ADMIN_TELEGRAM_IDS;
if (!adminIds) {
logger.warn('No admin IDs configured for escalation');
return '현재 관리자 연결이 불가합니다. 잠시 후 다시 시도해주세요.';
}
const ids = adminIds.split(',').map((id) => id.trim()).filter(Boolean);
if (ids.length === 0) {
return '현재 관리자 연결이 불가합니다. 잠시 후 다시 시도해주세요.';
}
// Get user info
const user = await env.DB
.prepare('SELECT username, first_name, telegram_id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ username: string | null; first_name: string | null; telegram_id: string }>();
const userName = user?.username ? `@${user.username}` : (user?.first_name ?? userId);
const escalationMessage = [
'🚨 <b>에스컬레이션 요청</b>',
'',
`사용자: ${userName} (${userId})`,
`세션: ${sessionType}`,
`메시지: ${userMessage.substring(0, 200)}`,
context ? `맥락: ${context}` : '',
'',
`시간: ${new Date().toISOString()}`,
]
.filter(Boolean)
.join('\n');
// Send to all admins with action buttons
for (const adminId of ids) {
await sendMessageWithKeyboard(
env.BOT_TOKEN,
parseInt(adminId),
escalationMessage,
[
[
{ text: '✅ 수락', callback_data: `esc:${userId}:accept` },
{ text: '❌ 거부', callback_data: `esc:${userId}:reject` },
],
]
).catch((e) => logger.error('Escalation notification failed', e as Error, { adminId }));
}
logger.info('Escalation sent to admins', {
userId,
sessionType,
adminCount: ids.length,
});
return '관리자에게 연결 요청을 보냈습니다. 잠시만 기다려주세요.';
} catch (error) {
logger.error('Escalation failed', error as Error, { userId });
return '관리자 연결 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
/**
* Admin takes over a user's conversation.
* Sends a message to the user on behalf of the admin.
*/
export async function handleAdminTakeover(
env: Env,
adminId: string,
userId: string,
message: string
): Promise<void> {
logger.info('Admin takeover', { adminId, userId });
try {
await sendMessage(env.BOT_TOKEN, parseInt(userId), message);
// Store the admin message as a conversation entry
await env.DB
.prepare(
`INSERT INTO conversations (user_id, role, content, request_id)
VALUES ((SELECT id FROM users WHERE telegram_id = ?), 'assistant', ?, ?)`
)
.bind(userId, `[관리자] ${message}`, crypto.randomUUID())
.run();
} catch (error) {
logger.error('Admin takeover failed', error as Error, { adminId, userId });
await notifyAdmins(env, `⚠️ 사용자 ${userId}에게 메시지 전송 실패`);
}
}

117
src/services/kv-cache.ts Normal file
View File

@@ -0,0 +1,117 @@
import { createLogger } from '../utils/logger';
const logger = createLogger('kv-cache');
/**
* KV Cache abstraction layer for consistent caching patterns
*/
export class KVCache {
constructor(private kv: KVNamespace, private prefix: string = '') {}
async get<T>(key: string): Promise<T | null> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
try {
const value = await this.kv.get(fullKey, 'json');
return value as T | null;
} catch (error) {
logger.error('KV get failed', error as Error, { key: fullKey });
return null;
}
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<boolean> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
try {
const options = ttlSeconds ? { expirationTtl: ttlSeconds } : undefined;
await this.kv.put(fullKey, JSON.stringify(value), options);
return true;
} catch (error) {
logger.error('KV set failed', error as Error, { key: fullKey });
return false;
}
}
async delete(key: string): Promise<boolean> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
try {
await this.kv.delete(fullKey);
return true;
} catch (error) {
logger.error('KV delete failed', error as Error, { key: fullKey });
return false;
}
}
/**
* Get or set pattern - fetch from cache or compute and store
*/
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttlSeconds?: number
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) {
logger.debug('Cache hit', { key });
return cached;
}
logger.debug('Cache miss', { key });
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
async exists(key: string): Promise<boolean> {
const value = await this.get(key);
return value !== null;
}
}
/**
* Create rate limiter cache instance
*/
export function createRateLimitCache(kv: KVNamespace): KVCache {
return new KVCache(kv, 'rate');
}
/**
* Create session cache instance
*/
export function createSessionCache(kv: KVNamespace): KVCache {
return new KVCache(kv, 'session');
}
/**
* Create query/general cache instance
*/
export function createQueryCache(kv: KVNamespace): KVCache {
return new KVCache(kv, 'query');
}
/**
* Rate limiting helper - returns true if request should be allowed
*/
export async function checkRateLimitWithCache(
cache: KVCache,
userId: string,
maxRequests: number = 30,
windowSeconds: number = 60
): Promise<boolean> {
const key = userId;
const now = Math.floor(Date.now() / 1000);
const data = await cache.get<{ count: number; windowStart: number }>(key);
if (!data || now - data.windowStart >= windowSeconds) {
await cache.set(key, { count: 1, windowStart: now }, windowSeconds);
return true;
}
if (data.count >= maxRequests) {
return false;
}
await cache.set(key, { count: data.count + 1, windowStart: data.windowStart }, windowSeconds);
return true;
}

View File

@@ -0,0 +1,60 @@
import { createLogger } from '../utils/logger';
import type { Env } from '../types';
const logger = createLogger('notification');
const TELEGRAM_API = 'https://api.telegram.org';
export async function notifyAdmins(env: Env, message: string): Promise<void> {
const adminIds = env.ADMIN_TELEGRAM_IDS;
if (!adminIds) {
logger.warn('ADMIN_TELEGRAM_IDS not configured, skipping notification');
return;
}
const ids = adminIds
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (ids.length === 0) {
logger.warn('No admin IDs found after parsing');
return;
}
const results = await Promise.allSettled(
ids.map((chatId) => sendTelegramMessage(env.BOT_TOKEN, chatId, message))
);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
logger.error('Some admin notifications failed', undefined, {
total: ids.length,
failed: failed.length,
});
} else {
logger.info('All admin notifications sent', { count: ids.length });
}
}
async function sendTelegramMessage(
botToken: string,
chatId: string,
text: string
): Promise<void> {
const url = `${TELEGRAM_API}/bot${botToken}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Telegram API error ${response.status}: ${body}`);
}
}

View File

@@ -0,0 +1,135 @@
import { createLogger } from '../utils/logger';
import type { PendingAction, PendingActionStatus } from '../types';
const logger = createLogger('pending-actions');
interface CreatePendingActionParams {
userId: number;
actionType: string;
target: string;
params: Record<string, unknown>;
}
export async function createPendingAction(
db: D1Database,
params: CreatePendingActionParams
): Promise<PendingAction> {
const result = await db
.prepare(
`INSERT INTO pending_actions (user_id, action_type, target, params)
VALUES (?, ?, ?, ?)
RETURNING *`
)
.bind(
params.userId,
params.actionType,
params.target,
JSON.stringify(params.params)
)
.first<PendingAction>();
if (!result) {
throw new Error('Failed to create pending action');
}
logger.info('Pending action created', {
actionId: result.id,
actionType: params.actionType,
target: params.target,
});
return result;
}
export async function approvePendingAction(
db: D1Database,
actionId: number,
approvedBy: number
): Promise<PendingAction | null> {
const result = await db
.prepare(
`UPDATE pending_actions
SET status = 'approved', approved_by = ?
WHERE id = ? AND status = 'pending'
RETURNING *`
)
.bind(approvedBy, actionId)
.first<PendingAction>();
if (!result) {
logger.warn('Pending action not found or not in pending status', { actionId });
return null;
}
logger.info('Pending action approved', { actionId, approvedBy });
return result;
}
export async function rejectPendingAction(
db: D1Database,
actionId: number,
approvedBy: number
): Promise<PendingAction | null> {
const result = await db
.prepare(
`UPDATE pending_actions
SET status = 'rejected', approved_by = ?
WHERE id = ? AND status = 'pending'
RETURNING *`
)
.bind(approvedBy, actionId)
.first<PendingAction>();
if (!result) {
logger.warn('Pending action not found or not in pending status', { actionId });
return null;
}
logger.info('Pending action rejected', { actionId, approvedBy });
return result;
}
export async function executePendingAction(
db: D1Database,
actionId: number
): Promise<PendingAction | null> {
const result = await db
.prepare(
`UPDATE pending_actions
SET status = 'executed', executed_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'approved'
RETURNING *`
)
.bind(actionId)
.first<PendingAction>();
if (!result) {
logger.warn('Pending action not found or not approved', { actionId });
return null;
}
logger.info('Pending action executed', { actionId });
return result;
}
export async function getPendingActions(
db: D1Database,
status?: PendingActionStatus
): Promise<PendingAction[]> {
if (status) {
const result = await db
.prepare(
`SELECT * FROM pending_actions WHERE status = ? ORDER BY created_at DESC LIMIT 50`
)
.bind(status)
.all<PendingAction>();
return result.results;
}
const result = await db
.prepare(
`SELECT * FROM pending_actions ORDER BY created_at DESC LIMIT 50`
)
.all<PendingAction>();
return result.results;
}

235
src/telegram.ts Normal file
View File

@@ -0,0 +1,235 @@
// ============================================
// Telegram Bot API Helpers
// ============================================
export class TelegramError extends Error {
constructor(
message: string,
public readonly code?: number,
public readonly description?: string
) {
super(message);
this.name = 'TelegramError';
}
}
export interface InlineKeyboardButton {
text: string;
url?: string;
callback_data?: string;
web_app?: { url: string };
}
async function callTelegramAPI(
token: string,
method: string,
body: Record<string, unknown>
): Promise<Response> {
const response = await fetch(
`https://api.telegram.org/bot${token}/${method}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!response.ok) {
let description = '';
try {
const errorData = await response.json() as { description?: string };
description = errorData.description || '';
} catch {
// JSON parse failure ignored
}
throw new TelegramError(
`Telegram API ${method} failed: ${response.status}`,
response.status,
description
);
}
return response;
}
function wrapTelegramCall(method: string, fn: () => Promise<Response>): Promise<void> {
return fn().then(() => undefined).catch((error: unknown) => {
if (error instanceof TelegramError) throw error;
throw new TelegramError(
`Network error in ${method}`,
undefined,
error instanceof Error ? error.message : String(error)
);
});
}
export async function sendMessage(
token: string,
chatId: number,
text: string,
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_to_message_id?: number;
disable_notification?: boolean;
}
): Promise<void> {
return wrapTelegramCall('sendMessage', () =>
callTelegramAPI(token, 'sendMessage', {
chat_id: chatId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_to_message_id: options?.reply_to_message_id,
disable_notification: options?.disable_notification,
})
);
}
export async function sendMessageWithKeyboard(
token: string,
chatId: number,
text: string,
keyboard: InlineKeyboardButton[][],
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
}
): Promise<void> {
return wrapTelegramCall('sendMessageWithKeyboard', () =>
callTelegramAPI(token, 'sendMessage', {
chat_id: chatId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_markup: { inline_keyboard: keyboard },
})
);
}
export async function sendChatAction(
token: string,
chatId: number,
action: 'typing' | 'upload_photo' | 'upload_document' = 'typing'
): Promise<void> {
return wrapTelegramCall('sendChatAction', () =>
callTelegramAPI(token, 'sendChatAction', {
chat_id: chatId,
action,
})
);
}
export async function answerCallbackQuery(
token: string,
callbackQueryId: string,
options?: {
text?: string;
show_alert?: boolean;
}
): Promise<void> {
return wrapTelegramCall('answerCallbackQuery', () =>
callTelegramAPI(token, 'answerCallbackQuery', {
callback_query_id: callbackQueryId,
text: options?.text,
show_alert: options?.show_alert,
})
);
}
export async function editMessageText(
token: string,
chatId: number,
messageId: number,
text: string,
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_markup?: { inline_keyboard: InlineKeyboardButton[][] };
}
): Promise<void> {
return wrapTelegramCall('editMessageText', () =>
callTelegramAPI(token, 'editMessageText', {
chat_id: chatId,
message_id: messageId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_markup: options?.reply_markup,
})
);
}
export async function sendPhoto(
token: string,
chatId: number,
photo: ArrayBuffer,
options?: {
caption?: string;
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_to_message_id?: number;
}
): Promise<void> {
const formData = new FormData();
formData.append('chat_id', String(chatId));
formData.append('photo', new Blob([photo], { type: 'image/png' }), 'diagram.png');
if (options?.caption) {
formData.append('caption', options.caption);
formData.append('parse_mode', options.parse_mode || 'HTML');
}
if (options?.reply_to_message_id) {
formData.append('reply_to_message_id', String(options.reply_to_message_id));
}
try {
const response = await fetch(
`https://api.telegram.org/bot${token}/sendPhoto`,
{ method: 'POST', body: formData }
);
if (!response.ok) {
let description = '';
try {
const errorData = await response.json() as { description?: string };
description = errorData.description || '';
} catch {
// JSON parse failure ignored
}
throw new TelegramError(
`Telegram API sendPhoto failed: ${response.status}`,
response.status,
description
);
}
} catch (error) {
if (error instanceof TelegramError) throw error;
throw new TelegramError(
'Network error in sendPhoto',
undefined,
error instanceof Error ? error.message : String(error)
);
}
}
export async function setWebhook(
token: string,
webhookUrl: string,
secretToken: string
): Promise<{ ok: boolean; description?: string }> {
const response = await fetch(
`https://api.telegram.org/bot${token}/setWebhook`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: webhookUrl,
secret_token: secretToken,
allowed_updates: ['message', 'callback_query'],
drop_pending_updates: true,
}),
}
);
return response.json() as Promise<{ ok: boolean; description?: string }>;
}
export async function getWebhookInfo(token: string): Promise<unknown> {
const response = await fetch(
`https://api.telegram.org/bot${token}/getWebhookInfo`
);
return response.json();
}

371
src/tools/admin-tool.ts Normal file
View File

@@ -0,0 +1,371 @@
import { createLogger } from '../utils/logger';
import { createAuditLog } from '../services/audit';
import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock';
import type { Env, ToolDefinition, AdminArgs, UserRole } from '../types';
const logger = createLogger('admin-tool');
export const adminTool: ToolDefinition = {
type: 'function',
function: {
name: 'admin',
description:
'관리자 전용 도구: 사용자 차단/해제, 역할 변경, 공지 발송, 입금 승인/거부, 대기 목록 조회.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: [
'block_user',
'unblock_user',
'set_role',
'broadcast',
'confirm_deposit',
'reject_deposit',
'list_pending',
],
description: '관리자 작업',
},
target_user_id: {
type: 'string',
description: '대상 사용자 Telegram ID (block/unblock/set_role)',
},
role: {
type: 'string',
enum: ['admin', 'user'],
description: '설정할 역할 (set_role)',
},
message: {
type: 'string',
description: '공지 메시지 (broadcast)',
},
transaction_id: {
type: 'number',
description: '거래 ID (confirm_deposit/reject_deposit)',
},
reason: {
type: 'string',
description: '사유 (reject_deposit, block_user)',
},
},
required: ['action'],
},
},
};
export async function executeAdmin(
args: AdminArgs,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
// Verify admin role
const admin = await db
.prepare('SELECT id, role FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number; role: string }>();
if (!admin || admin.role !== 'admin') {
return '관리자 권한이 필요합니다.';
}
switch (args.action) {
case 'block_user':
return await blockUser(db, admin.id, args.target_user_id, args.reason);
case 'unblock_user':
return await unblockUser(db, admin.id, args.target_user_id);
case 'set_role':
return await setRole(db, admin.id, args.target_user_id, args.role);
case 'broadcast':
return await broadcast(env, db, admin.id, args.message);
case 'confirm_deposit':
return await confirmDeposit(db, admin.id, args.transaction_id);
case 'reject_deposit':
return await rejectDeposit(db, admin.id, args.transaction_id, args.reason);
case 'list_pending':
return await listPending(db);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Admin tool error', error as Error, { action: args.action });
return '관리자 작업 중 오류가 발생했습니다.';
}
}
async function blockUser(
db: D1Database,
adminId: number,
targetUserId?: string,
reason?: string
): Promise<string> {
if (!targetUserId) return '대상 사용자 ID를 지정해주세요.';
const result = await db
.prepare(
`UPDATE users SET is_blocked = 1, blocked_reason = ?, updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = ? RETURNING id, username`
)
.bind(reason ?? null, targetUserId)
.first<{ id: number; username: string | null }>();
if (!result) return '사용자를 찾을 수 없습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'block_user',
resourceType: 'user',
resourceId: targetUserId,
details: reason ?? null,
result: 'success',
});
const name = result.username ?? targetUserId;
return `사용자 ${name}이(가) 차단되었습니다.${reason ? ` 사유: ${reason}` : ''}`;
}
async function unblockUser(
db: D1Database,
adminId: number,
targetUserId?: string
): Promise<string> {
if (!targetUserId) return '대상 사용자 ID를 지정해주세요.';
const result = await db
.prepare(
`UPDATE users SET is_blocked = 0, blocked_reason = NULL, updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = ? RETURNING id, username`
)
.bind(targetUserId)
.first<{ id: number; username: string | null }>();
if (!result) return '사용자를 찾을 수 없습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'unblock_user',
resourceType: 'user',
resourceId: targetUserId,
result: 'success',
});
const name = result.username ?? targetUserId;
return `사용자 ${name}의 차단이 해제되었습니다.`;
}
async function setRole(
db: D1Database,
adminId: number,
targetUserId?: string,
role?: UserRole
): Promise<string> {
if (!targetUserId) return '대상 사용자 ID를 지정해주세요.';
if (!role) return '설정할 역할을 지정해주세요.';
const result = await db
.prepare(
`UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = ? RETURNING id, username`
)
.bind(role, targetUserId)
.first<{ id: number; username: string | null }>();
if (!result) return '사용자를 찾을 수 없습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'set_role',
resourceType: 'user',
resourceId: targetUserId,
details: `role: ${role}`,
result: 'success',
});
const name = result.username ?? targetUserId;
return `사용자 ${name}의 역할이 ${role}(으)로 변경되었습니다.`;
}
async function broadcast(
env?: Env,
db?: D1Database,
adminId?: number,
message?: string
): Promise<string> {
if (!env || !db || !adminId) return '환경 설정이 올바르지 않습니다.';
if (!message) return '공지 메시지를 입력해주세요.';
const { notifyAdmins } = await import('../services/notification');
await notifyAdmins(env, `[공지] ${message}`);
await createAuditLog(db, {
actorId: adminId,
action: 'broadcast',
resourceType: 'notification',
details: message,
result: 'success',
});
return '공지가 발송되었습니다.';
}
async function confirmDeposit(
db: D1Database,
adminId: number,
transactionId?: number
): Promise<string> {
if (!transactionId) return '거래 ID를 지정해주세요.';
return executeWithOptimisticLock(db, async () => {
const tx = await db
.prepare(
'SELECT id, user_id, amount, status FROM transactions WHERE id = ? AND type = \'deposit\''
)
.bind(transactionId)
.first<{ id: number; user_id: number; amount: number; status: string }>();
if (!tx) return '거래를 찾을 수 없습니다.';
if (tx.status !== 'pending') return `이미 처리된 거래입니다 (상태: ${tx.status}).`;
// Get current wallet version
const wallet = await db
.prepare('SELECT id, balance, version FROM wallets WHERE user_id = ?')
.bind(tx.user_id)
.first<{ id: number; balance: number; version: number }>();
if (!wallet) return '사용자의 지갑을 찾을 수 없습니다.';
// Update with optimistic lock
const updateResult = await db
.prepare(
`UPDATE wallets SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND version = ?`
)
.bind(tx.amount, tx.user_id, wallet.version)
.run();
if (!updateResult.meta.changes || updateResult.meta.changes === 0) {
throw new OptimisticLockError('Wallet version conflict');
}
// Confirm transaction
await db
.prepare(
`UPDATE transactions SET status = 'confirmed', confirmed_by = ?, confirmed_at = CURRENT_TIMESTAMP
WHERE id = ?`
)
.bind(adminId, transactionId)
.run();
await createAuditLog(db, {
actorId: adminId,
action: 'confirm_deposit',
resourceType: 'transaction',
resourceId: String(transactionId),
details: `amount: ${tx.amount}`,
result: 'success',
});
const newBalance = wallet.balance + tx.amount;
return `입금 #${transactionId} 승인 완료.\n금액: ${tx.amount.toLocaleString()}\n새 잔액: ${newBalance.toLocaleString()}`;
});
}
async function rejectDeposit(
db: D1Database,
adminId: number,
transactionId?: number,
reason?: string
): Promise<string> {
if (!transactionId) return '거래 ID를 지정해주세요.';
const result = await db
.prepare(
`UPDATE transactions SET status = 'rejected', confirmed_by = ?, confirmed_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'pending' AND type = 'deposit'
RETURNING id, amount`
)
.bind(adminId, transactionId)
.first<{ id: number; amount: number }>();
if (!result) return '처리할 수 없는 거래입니다. 대기 중인 입금 거래만 거부할 수 있습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'reject_deposit',
resourceType: 'transaction',
resourceId: String(transactionId),
details: reason ?? null,
result: 'success',
});
return `입금 #${transactionId} 거부 완료.\n금액: ${result.amount.toLocaleString()}${reason ? `\n사유: ${reason}` : ''}`;
}
async function listPending(db: D1Database): Promise<string> {
// Pending transactions
const txResult = await db
.prepare(
`SELECT t.id, t.user_id, t.amount, t.depositor_name, t.created_at, u.username, u.telegram_id
FROM transactions t
JOIN users u ON t.user_id = u.id
WHERE t.status = 'pending' AND t.type = 'deposit'
ORDER BY t.created_at DESC LIMIT 20`
)
.all<{
id: number;
user_id: number;
amount: number;
depositor_name: string | null;
created_at: string;
username: string | null;
telegram_id: string;
}>();
// Pending actions
const actionResult = await db
.prepare(
`SELECT pa.id, pa.action_type, pa.target, pa.created_at, u.username, u.telegram_id
FROM pending_actions pa
JOIN users u ON pa.user_id = u.id
WHERE pa.status = 'pending'
ORDER BY pa.created_at DESC LIMIT 20`
)
.all<{
id: number;
action_type: string;
target: string;
created_at: string;
username: string | null;
telegram_id: string;
}>();
const parts: string[] = [];
if (txResult.results.length > 0) {
const lines = txResult.results.map((t) => {
const name = t.username ?? t.telegram_id;
const date = t.created_at.split('T')[0];
return ` #${t.id} ${name} ${t.amount.toLocaleString()}원 (${t.depositor_name ?? '-'}) ${date}`;
});
parts.push(`대기 중 입금 (${txResult.results.length}건):\n${lines.join('\n')}`);
} else {
parts.push('대기 중 입금: 없음');
}
if (actionResult.results.length > 0) {
const lines = actionResult.results.map((a) => {
const name = a.username ?? a.telegram_id;
const date = a.created_at.split('T')[0];
return ` #${a.id} [${a.action_type}] ${a.target} by ${name} ${date}`;
});
parts.push(`대기 중 작업 (${actionResult.results.length}건):\n${lines.join('\n')}`);
} else {
parts.push('대기 중 작업: 없음');
}
return parts.join('\n\n');
}

109
src/tools/d2-tool.ts Normal file
View File

@@ -0,0 +1,109 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, RenderD2Args } from '../types';
const logger = createLogger('d2-tool');
export const renderD2Tool: ToolDefinition = {
type: 'function',
function: {
name: 'render_d2',
description:
'D2 다이어그램 렌더링: D2 소스 코드를 이미지(SVG/PNG)로 변환합니다.',
parameters: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'D2 다이어그램 소스 코드',
},
format: {
type: 'string',
enum: ['svg', 'png'],
description: '출력 포맷 (기본: svg)',
},
},
required: ['source'],
},
},
};
export async function executeRenderD2(
args: RenderD2Args,
env?: Env,
_userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!env?.D2_RENDER_URL) {
return 'D2 렌더링 서비스가 설정되지 않았습니다.';
}
if (!env.R2_BUCKET) {
return 'R2 스토리지가 설정되지 않았습니다.';
}
const format = args.format ?? 'svg';
const sourceHash = await hashSource(args.source + format);
// Check cache
if (db) {
const cached = await db
.prepare('SELECT r2_key FROM d2_cache WHERE source_hash = ?')
.bind(sourceHash)
.first<{ r2_key: string }>();
if (cached) {
logger.info('D2 cache hit', { hash: sourceHash });
return `다이어그램이 준비되었습니다: /d2/${cached.r2_key}`;
}
}
// Render via external service
const response = await fetch(env.D2_RENDER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: args.source, format }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('D2 render API failed', undefined, {
status: response.status,
error: errorText,
});
return `다이어그램 렌더링 실패: ${response.statusText}`;
}
const imageData = await response.arrayBuffer();
const contentType = format === 'svg' ? 'image/svg+xml' : 'image/png';
const r2Key = `d2/${sourceHash}.${format}`;
// Store in R2
await env.R2_BUCKET.put(r2Key, imageData, {
httpMetadata: { contentType },
});
// Cache metadata
if (db) {
await db
.prepare(
'INSERT INTO d2_cache (source_hash, r2_key, format) VALUES (?, ?, ?)'
)
.bind(sourceHash, r2Key, format)
.run();
}
logger.info('D2 diagram rendered and cached', { hash: sourceHash, format });
return `다이어그램이 준비되었습니다: /d2/${r2Key}`;
} catch (error) {
logger.error('D2 tool error', error as Error);
return '다이어그램 렌더링 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function hashSource(source: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(source);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}

252
src/tools/domain-tool.ts Normal file
View File

@@ -0,0 +1,252 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, ManageDomainArgs, Domain } from '../types';
const logger = createLogger('domain-tool');
export const manageDomainTool: ToolDefinition = {
type: 'function',
function: {
name: 'manage_domain',
description:
'도메인 관리: 도메인 조회, WHOIS 확인, 네임서버 설정, 가격 확인 등. 사용자의 도메인 정보를 관리합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['check', 'whois', 'list', 'info', 'set_ns', 'price'],
description:
'check: 도메인 등록 가능 여부, whois: WHOIS 조회, list: 보유 도메인 목록, info: 도메인 상세 정보, set_ns: 네임서버 변경, price: 가격 조회',
},
domain: {
type: 'string',
description: '대상 도메인 (예: example.com)',
},
nameservers: {
type: 'array',
items: { type: 'string' },
description: 'set_ns 시 설정할 네임서버 목록',
},
tld: {
type: 'string',
description: 'price 조회 시 TLD (예: com, net, io)',
},
},
required: ['action'],
},
},
};
export async function executeManageDomain(
args: ManageDomainArgs,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'list':
return await listDomains(db, userId);
case 'info':
return await getDomainInfo(db, userId, args.domain);
case 'check':
return await checkDomain(env, args.domain);
case 'whois':
return await whoisDomain(env, args.domain);
case 'price':
return await getDomainPrice(env, args.domain, args.tld);
case 'set_ns':
return await setNameservers(db, userId, args.domain, args.nameservers);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Domain tool error', error as Error, { action: args.action });
return '도메인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function listDomains(
db?: D1Database,
userId?: string
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const result = await db
.prepare(
'SELECT domain, status, expiry_date, auto_renew FROM domains WHERE user_id = ? ORDER BY domain'
)
.bind(user.id)
.all<Pick<Domain, 'domain' | 'status' | 'expiry_date' | 'auto_renew'>>();
if (result.results.length === 0) {
return '보유 중인 도메인이 없습니다.';
}
const statusLabel: Record<string, string> = {
active: 'Active',
expired: 'Expired',
pending: 'Pending',
suspended: 'Suspended',
};
const lines = result.results.map((d) => {
const status = statusLabel[d.status] ?? d.status;
const expiry = d.expiry_date ? d.expiry_date.split('T')[0] : '-';
const renew = d.auto_renew ? 'ON' : 'OFF';
return `- ${d.domain} [${status}] 만료: ${expiry} 자동갱신: ${renew}`;
});
return `보유 도메인 (${result.results.length}개):\n${lines.join('\n')}`;
}
async function getDomainInfo(
db?: D1Database,
userId?: string,
domain?: string
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!domain) return '도메인을 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const d = await db
.prepare('SELECT * FROM domains WHERE user_id = ? AND domain = ?')
.bind(user.id, domain)
.first<Domain>();
if (!d) return `도메인 ${domain}을(를) 찾을 수 없습니다.`;
const ns = d.nameservers ?? '-';
const expiry = d.expiry_date ? d.expiry_date.split('T')[0] : '-';
return [
`도메인: ${d.domain}`,
`상태: ${d.status}`,
`등록기관: ${d.registrar ?? '-'}`,
`네임서버: ${ns}`,
`만료일: ${expiry}`,
`자동갱신: ${d.auto_renew ? 'ON' : 'OFF'}`,
`등록일: ${d.created_at.split('T')[0]}`,
].join('\n');
}
async function checkDomain(env?: Env, domain?: string): Promise<string> {
if (!domain) return '도메인을 지정해주세요.';
if (!env?.WHOIS_API_URL) return 'WHOIS API가 설정되지 않았습니다.';
const response = await fetch(`${env.WHOIS_API_URL}/check?domain=${encodeURIComponent(domain)}`);
if (!response.ok) {
return `도메인 조회 실패: ${response.statusText}`;
}
const data = await response.json() as { available?: boolean; domain?: string };
if (data.available) {
return `${domain}은(는) 등록 가능한 도메인입니다.`;
}
return `${domain}은(는) 이미 등록된 도메인입니다.`;
}
async function whoisDomain(env?: Env, domain?: string): Promise<string> {
if (!domain) return '도메인을 지정해주세요.';
if (!env?.WHOIS_API_URL) return 'WHOIS API가 설정되지 않았습니다.';
const response = await fetch(`${env.WHOIS_API_URL}/whois?domain=${encodeURIComponent(domain)}`);
if (!response.ok) {
return `WHOIS 조회 실패: ${response.statusText}`;
}
const data = await response.json() as {
registrar?: string;
creation_date?: string;
expiration_date?: string;
nameservers?: string[];
status?: string;
};
return [
`WHOIS 정보 - ${domain}`,
`등록기관: ${data.registrar ?? '-'}`,
`등록일: ${data.creation_date ?? '-'}`,
`만료일: ${data.expiration_date ?? '-'}`,
`네임서버: ${data.nameservers?.join(', ') ?? '-'}`,
`상태: ${data.status ?? '-'}`,
].join('\n');
}
async function getDomainPrice(
env?: Env,
domain?: string,
tld?: string
): Promise<string> {
if (!env?.NAMECHEAP_API_URL) return '가격 조회 API가 설정되지 않았습니다.';
const query = domain ?? tld;
if (!query) return '도메인 또는 TLD를 지정해주세요.';
const response = await fetch(
`${env.NAMECHEAP_API_URL}/pricing?domain=${encodeURIComponent(query)}`
);
if (!response.ok) {
return `가격 조회 실패: ${response.statusText}`;
}
const data = await response.json() as {
prices?: Array<{ tld: string; register: number; renew: number; currency: string }>;
};
if (!data.prices || data.prices.length === 0) {
return '가격 정보를 찾을 수 없습니다.';
}
const lines = data.prices.map(
(p) =>
`.${p.tld}: 등록 ${p.register.toLocaleString()}${p.currency} / 갱신 ${p.renew.toLocaleString()}${p.currency}`
);
return `도메인 가격 정보:\n${lines.join('\n')}`;
}
async function setNameservers(
db?: D1Database,
userId?: string,
domain?: string,
nameservers?: string[]
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!domain) return '도메인을 지정해주세요.';
if (!nameservers || nameservers.length === 0) return '네임서버를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const d = await db
.prepare('SELECT id FROM domains WHERE user_id = ? AND domain = ?')
.bind(user.id, domain)
.first<{ id: number }>();
if (!d) return `도메인 ${domain}을(를) 찾을 수 없습니다.`;
// Safety: create pending action instead of direct modification
const { createPendingAction } = await import('../services/pending-actions');
const action = await createPendingAction(db, {
userId: user.id,
actionType: 'set_nameservers',
target: domain,
params: { nameservers },
});
return `네임서버 변경 요청이 등록되었습니다 (요청 #${action.id}).\n관리자 승인 후 적용됩니다.\n\n대상: ${domain}\n네임서버: ${nameservers.join(', ')}`;
}

210
src/tools/index.ts Normal file
View File

@@ -0,0 +1,210 @@
import { z } from 'zod';
import { createLogger } from '../utils/logger';
import { detectToolCategories } from '../utils/patterns';
import type { Env, ToolDefinition } from '../types';
import { manageDomainTool, executeManageDomain } from './domain-tool';
import { manageWalletTool, executeManageWallet } from './wallet-tool';
import { manageServerTool, executeManageServer } from './server-tool';
import { checkServiceTool, executeCheckService } from './service-tool';
import { renderD2Tool, executeRenderD2 } from './d2-tool';
import { adminTool, executeAdmin } from './admin-tool';
import { searchKnowledgeTool, executeSearchKnowledge } from './knowledge-tool';
const logger = createLogger('tools');
// ============================================================================
// Zod Validation Schemas
// ============================================================================
const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,251}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/;
const ManageDomainArgsSchema = z.object({
action: z.enum(['check', 'whois', 'list', 'info', 'set_ns', 'price']),
domain: z.string().max(253).regex(DOMAIN_REGEX, '올바른 도메인 형식이 아닙니다').optional(),
nameservers: z.array(z.string().max(253)).max(10).optional(),
tld: z.string().max(20).optional(),
});
const ManageWalletArgsSchema = z.object({
action: z.enum(['balance', 'account', 'request', 'history', 'cancel']),
depositor_name: z.string().max(100).optional(),
amount: z.number().positive().max(100_000_000).optional(),
transaction_id: z.number().int().positive().optional(),
limit: z.number().int().positive().max(50).optional(),
});
const ManageServerArgsSchema = z.object({
action: z.enum(['list', 'info', 'start', 'stop', 'reboot']),
server_id: z.number().int().positive().optional(),
});
const CheckServiceArgsSchema = z.object({
action: z.enum(['status', 'list']),
service_type: z.enum(['ddos', 'vpn', 'all']).optional(),
service_id: z.number().int().positive().optional(),
});
const RenderD2ArgsSchema = z.object({
source: z.string().min(1).max(10000),
format: z.enum(['svg', 'png']).optional(),
});
const AdminArgsSchema = z.object({
action: z.enum([
'block_user', 'unblock_user', 'set_role', 'broadcast',
'confirm_deposit', 'reject_deposit', 'list_pending',
]),
target_user_id: z.string().max(50).optional(),
role: z.enum(['admin', 'user']).optional(),
message: z.string().max(4000).optional(),
transaction_id: z.number().int().positive().optional(),
reason: z.string().max(500).optional(),
});
const SearchKnowledgeArgsSchema = z.object({
query: z.string().min(1).max(200),
category: z.string().max(50).optional(),
});
// ============================================================================
// Validated Executor Helper
// ============================================================================
function createValidatedExecutor<T extends z.ZodType>(
schema: T,
executor: (data: z.infer<T>, env?: Env, userId?: string, db?: D1Database) => Promise<string>,
toolName: string
) {
return async (
args: Record<string, unknown>,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> => {
const result = schema.safeParse(args);
if (!result.success) {
const issues = result.error.issues.map((issue) => {
if (issue.code === 'invalid_value' && 'values' in issue) {
return `허용된 값: ${(issue.values as string[]).join(', ')}`;
}
return issue.message;
});
logger.error(`Invalid ${toolName} args`, new Error(result.error.message), { args });
return `잘못된 입력: ${issues.join(', ')}`;
}
return executor(result.data, env, userId, db);
};
}
// ============================================================================
// Tool Executor Registry
// ============================================================================
const toolExecutors: Record<
string,
(args: Record<string, unknown>, env?: Env, userId?: string, db?: D1Database) => Promise<string>
> = {
manage_domain: createValidatedExecutor(ManageDomainArgsSchema, executeManageDomain, 'domain'),
manage_wallet: createValidatedExecutor(ManageWalletArgsSchema, executeManageWallet, 'wallet'),
manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'),
check_service: createValidatedExecutor(CheckServiceArgsSchema, executeCheckService, 'service'),
render_d2: createValidatedExecutor(RenderD2ArgsSchema, executeRenderD2, 'd2'),
admin: createValidatedExecutor(AdminArgsSchema, executeAdmin, 'admin'),
search_knowledge: createValidatedExecutor(SearchKnowledgeArgsSchema, executeSearchKnowledge, 'knowledge'),
};
// ============================================================================
// All Tools Array
// ============================================================================
export const tools: ToolDefinition[] = [
manageDomainTool,
manageWalletTool,
manageServerTool,
checkServiceTool,
renderD2Tool,
adminTool,
searchKnowledgeTool,
];
// ============================================================================
// Tool Categories
// ============================================================================
export const TOOL_CATEGORIES: Record<string, string[]> = {
domain: [manageDomainTool.function.name],
billing: [manageWalletTool.function.name],
server: [manageServerTool.function.name],
security: [checkServiceTool.function.name],
asset: [
manageDomainTool.function.name,
manageServerTool.function.name,
checkServiceTool.function.name,
],
troubleshoot: [searchKnowledgeTool.function.name],
onboarding: [searchKnowledgeTool.function.name],
};
// ============================================================================
// Message-Based Tool Selection
// ============================================================================
export function selectToolsForMessage(message: string): ToolDefinition[] {
const detectedCategories = detectToolCategories(message);
// No pattern match: return knowledge search only (token saving)
if (detectedCategories.length === 0) {
logger.info('패턴 매칭 없음 → 지식 검색만 사용 (토큰 절약)');
return [searchKnowledgeTool];
}
const selectedNames = new Set(
detectedCategories.flatMap((cat) => TOOL_CATEGORIES[cat] ?? [])
);
// Always include knowledge search
selectedNames.add(searchKnowledgeTool.function.name);
const selectedTools = tools.filter((t) => selectedNames.has(t.function.name));
logger.info('도구 선택 완료', {
message: message.substring(0, 100),
categories: detectedCategories.join(', '),
selectedTools: selectedTools.map((t) => t.function.name).join(', '),
});
return selectedTools;
}
// ============================================================================
// Tool Execution Dispatcher
// ============================================================================
export async function executeTool(
name: string,
args: Record<string, unknown>,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
const executor = toolExecutors[name];
if (!executor) {
return `알 수 없는 도구: ${name}`;
}
return await executor(args, env, userId, db);
} catch (error) {
logger.error('Tool execution error', error as Error, { name, args });
return '도구 실행 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
// Re-export tool definitions
export { manageDomainTool } from './domain-tool';
export { manageWalletTool } from './wallet-tool';
export { manageServerTool } from './server-tool';
export { checkServiceTool } from './service-tool';
export { renderD2Tool } from './d2-tool';
export { adminTool } from './admin-tool';
export { searchKnowledgeTool } from './knowledge-tool';

View File

@@ -0,0 +1,84 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, KnowledgeArticle } from '../types';
const logger = createLogger('knowledge-tool');
export const searchKnowledgeTool: ToolDefinition = {
type: 'function',
function: {
name: 'search_knowledge',
description:
'지식 베이스 검색: FAQ, 가이드, 매뉴얼 등을 검색합니다.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '검색어',
},
category: {
type: 'string',
description: '카테고리 필터 (예: faq, guide, manual)',
},
},
required: ['query'],
},
},
};
export async function executeSearchKnowledge(
args: { query: string; category?: string },
_env?: Env,
_userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!db) return '데이터베이스 연결 정보가 없습니다.';
const searchTerm = `%${args.query}%`;
let result;
if (args.category) {
result = await db
.prepare(
`SELECT id, category, title, content, tags
FROM knowledge_articles
WHERE is_active = 1 AND category = ?
AND (title LIKE ? OR content LIKE ?)
ORDER BY updated_at DESC
LIMIT 5`
)
.bind(args.category, searchTerm, searchTerm)
.all<Pick<KnowledgeArticle, 'id' | 'category' | 'title' | 'content' | 'tags'>>();
} else {
result = await db
.prepare(
`SELECT id, category, title, content, tags
FROM knowledge_articles
WHERE is_active = 1
AND (title LIKE ? OR content LIKE ?)
ORDER BY updated_at DESC
LIMIT 5`
)
.bind(searchTerm, searchTerm)
.all<Pick<KnowledgeArticle, 'id' | 'category' | 'title' | 'content' | 'tags'>>();
}
if (result.results.length === 0) {
return `"${args.query}"에 대한 검색 결과가 없습니다.`;
}
const articles = result.results.map((a) => {
const tags = a.tags ? ` [${a.tags}]` : '';
// Truncate content for display
const preview =
a.content.length > 200 ? a.content.substring(0, 200) + '...' : a.content;
return `[${a.category}] ${a.title}${tags}\n${preview}`;
});
return `검색 결과 (${result.results.length}건):\n\n${articles.join('\n\n---\n\n')}`;
} catch (error) {
logger.error('Knowledge search error', error as Error, { query: args.query });
return '지식 베이스 검색 중 오류가 발생했습니다.';
}
}

176
src/tools/server-tool.ts Normal file
View File

@@ -0,0 +1,176 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, ManageServerArgs, Server } from '../types';
const logger = createLogger('server-tool');
export const manageServerTool: ToolDefinition = {
type: 'function',
function: {
name: 'manage_server',
description:
'서버 관리: 보유 서버 목록 조회, 서버 상세 정보, 시작/중지/재시작 요청.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['list', 'info', 'start', 'stop', 'reboot'],
description:
'list: 서버 목록, info: 서버 상세 정보, start/stop/reboot: 서버 제어 (관리자 승인 필요)',
},
server_id: {
type: 'number',
description: '서버 ID (info, start, stop, reboot 시 필수)',
},
},
required: ['action'],
},
},
};
export async function executeManageServer(
args: ManageServerArgs,
_env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'list':
return await listServers(db, userId);
case 'info':
return await getServerInfo(db, userId, args.server_id);
case 'start':
case 'stop':
case 'reboot':
return await requestServerAction(db, userId, args.action, args.server_id);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Server tool error', error as Error, { action: args.action });
return '서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function listServers(db?: D1Database, userId?: string): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const result = await db
.prepare(
`SELECT id, label, ip_address, status, provider, spec_label, monthly_price
FROM servers WHERE user_id = ? AND status != 'terminated'
ORDER BY id`
)
.bind(user.id)
.all<Pick<Server, 'id' | 'label' | 'ip_address' | 'status' | 'provider' | 'spec_label' | 'monthly_price'>>();
if (result.results.length === 0) {
return '보유 중인 서버가 없습니다.';
}
const statusLabel: Record<string, string> = {
pending: 'Pending',
provisioning: 'Provisioning',
running: 'Running',
stopped: 'Stopped',
failed: 'Failed',
};
const lines = result.results.map((s) => {
const name = s.label ?? `서버 #${s.id}`;
const status = statusLabel[s.status] ?? s.status;
const ip = s.ip_address ?? '-';
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
return `#${s.id} ${name} [${status}] IP: ${ip} ${price}`;
});
return `보유 서버 (${result.results.length}대):\n${lines.join('\n')}`;
}
async function getServerInfo(
db?: D1Database,
userId?: string,
serverId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!serverId) return '서버 ID를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const s = await db
.prepare('SELECT * FROM servers WHERE id = ? AND user_id = ?')
.bind(serverId, user.id)
.first<Server>();
if (!s) return `서버 #${serverId}을(를) 찾을 수 없습니다.`;
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
const provisioned = s.provisioned_at ? s.provisioned_at.split('T')[0] : '-';
const expires = s.expires_at ? s.expires_at.split('T')[0] : '-';
return [
`서버 #${s.id} 상세 정보`,
`이름: ${s.label ?? '-'}`,
`상태: ${s.status}`,
`제공업체: ${s.provider}`,
`IP: ${s.ip_address ?? '-'}`,
`리전: ${s.region ?? '-'}`,
`스펙: ${s.spec_label ?? '-'}`,
`이미지: ${s.image ?? '-'}`,
`월 요금: ${price}`,
`프로비저닝일: ${provisioned}`,
`만료일: ${expires}`,
].join('\n');
}
async function requestServerAction(
db: D1Database | undefined,
userId: string | undefined,
action: 'start' | 'stop' | 'reboot',
serverId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!serverId) return '서버 ID를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const server = await db
.prepare('SELECT id, label, status FROM servers WHERE id = ? AND user_id = ?')
.bind(serverId, user.id)
.first<{ id: number; label: string | null; status: string }>();
if (!server) return `서버 #${serverId}을(를) 찾을 수 없습니다.`;
const actionLabel: Record<string, string> = {
start: '시작',
stop: '중지',
reboot: '재시작',
};
// Safety: create pending action instead of direct execution
const { createPendingAction } = await import('../services/pending-actions');
const pending = await createPendingAction(db, {
userId: user.id,
actionType: `server_${action}`,
target: `server:${serverId}`,
params: { serverId, action },
});
const name = server.label ?? `서버 #${server.id}`;
return `${name} ${actionLabel[action]} 요청이 등록되었습니다 (요청 #${pending.id}).\n관리자 승인 후 실행됩니다.`;
}

174
src/tools/service-tool.ts Normal file
View File

@@ -0,0 +1,174 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, CheckServiceArgs, DdosService, VpnService } from '../types';
const logger = createLogger('service-tool');
export const checkServiceTool: ToolDefinition = {
type: 'function',
function: {
name: 'check_service',
description:
'DDoS 방어/VPN 서비스 상태 및 목록 조회.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'list'],
description: 'status: 특정 서비스 상태, list: 서비스 목록',
},
service_type: {
type: 'string',
enum: ['ddos', 'vpn', 'all'],
description: '서비스 유형 (기본: all)',
},
service_id: {
type: 'number',
description: '특정 서비스 ID (status 시)',
},
},
required: ['action'],
},
},
};
export async function executeCheckService(
args: CheckServiceArgs,
_env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'list':
return await listServices(db, userId, args.service_type);
case 'status':
return await getServiceStatus(db, userId, args.service_type, args.service_id);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Service tool error', error as Error, { action: args.action });
return '서비스 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function listServices(
db?: D1Database,
userId?: string,
serviceType?: 'ddos' | 'vpn' | 'all'
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const type = serviceType ?? 'all';
const parts: string[] = [];
if (type === 'ddos' || type === 'all') {
const ddos = await db
.prepare(
'SELECT id, target, protection_level, status, monthly_price, expiry_date FROM services_ddos WHERE user_id = ? ORDER BY id'
)
.bind(user.id)
.all<Pick<DdosService, 'id' | 'target' | 'protection_level' | 'status' | 'monthly_price' | 'expiry_date'>>();
if (ddos.results.length > 0) {
const lines = ddos.results.map((s) => {
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
const expiry = s.expiry_date ? s.expiry_date.split('T')[0] : '-';
return ` #${s.id} ${s.target} [${s.protection_level}] ${s.status} ${price} 만료: ${expiry}`;
});
parts.push(`DDoS 방어 (${ddos.results.length}개):\n${lines.join('\n')}`);
} else {
parts.push('DDoS 방어: 이용 중인 서비스 없음');
}
}
if (type === 'vpn' || type === 'all') {
const vpn = await db
.prepare(
'SELECT id, protocol, status, endpoint, monthly_price, expiry_date FROM services_vpn WHERE user_id = ? ORDER BY id'
)
.bind(user.id)
.all<Pick<VpnService, 'id' | 'protocol' | 'status' | 'endpoint' | 'monthly_price' | 'expiry_date'>>();
if (vpn.results.length > 0) {
const lines = vpn.results.map((s) => {
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
const expiry = s.expiry_date ? s.expiry_date.split('T')[0] : '-';
return ` #${s.id} ${s.protocol} [${s.status}] ${s.endpoint ?? '-'} ${price} 만료: ${expiry}`;
});
parts.push(`VPN (${vpn.results.length}개):\n${lines.join('\n')}`);
} else {
parts.push('VPN: 이용 중인 서비스 없음');
}
}
return parts.join('\n\n');
}
async function getServiceStatus(
db?: D1Database,
userId?: string,
serviceType?: 'ddos' | 'vpn' | 'all',
serviceId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!serviceId) return '서비스 ID를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
// Try DDoS first if type not specified or is ddos
if (!serviceType || serviceType === 'ddos' || serviceType === 'all') {
const ddos = await db
.prepare('SELECT * FROM services_ddos WHERE id = ? AND user_id = ?')
.bind(serviceId, user.id)
.first<DdosService>();
if (ddos) {
const price = ddos.monthly_price ? `${ddos.monthly_price.toLocaleString()}원/월` : '-';
const expiry = ddos.expiry_date ? ddos.expiry_date.split('T')[0] : '-';
return [
`DDoS 방어 서비스 #${ddos.id}`,
`대상: ${ddos.target}`,
`방어 레벨: ${ddos.protection_level}`,
`상태: ${ddos.status}`,
`제공업체: ${ddos.provider ?? '-'}`,
`월 요금: ${price}`,
`만료일: ${expiry}`,
].join('\n');
}
}
// Try VPN if type not specified or is vpn
if (!serviceType || serviceType === 'vpn' || serviceType === 'all') {
const vpn = await db
.prepare('SELECT * FROM services_vpn WHERE id = ? AND user_id = ?')
.bind(serviceId, user.id)
.first<VpnService>();
if (vpn) {
const price = vpn.monthly_price ? `${vpn.monthly_price.toLocaleString()}원/월` : '-';
const expiry = vpn.expiry_date ? vpn.expiry_date.split('T')[0] : '-';
return [
`VPN 서비스 #${vpn.id}`,
`프로토콜: ${vpn.protocol}`,
`상태: ${vpn.status}`,
`엔드포인트: ${vpn.endpoint ?? '-'}`,
`월 요금: ${price}`,
`만료일: ${expiry}`,
].join('\n');
}
}
return `서비스 #${serviceId}을(를) 찾을 수 없습니다.`;
}

238
src/tools/wallet-tool.ts Normal file
View File

@@ -0,0 +1,238 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, ManageWalletArgs, Transaction } from '../types';
const logger = createLogger('wallet-tool');
export const manageWalletTool: ToolDefinition = {
type: 'function',
function: {
name: 'manage_wallet',
description:
'예치금/지갑 관리: 잔액 조회, 입금 계좌 안내, 입금 요청, 거래 내역, 요청 취소.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['balance', 'account', 'request', 'history', 'cancel'],
description:
'balance: 잔액 조회, account: 입금 계좌 안내, request: 입금 요청 등록, history: 거래 내역, cancel: 대기 중 요청 취소',
},
depositor_name: {
type: 'string',
description: '입금자명 (request 시 필수)',
},
amount: {
type: 'number',
description: '입금 금액 (request 시 필수)',
},
transaction_id: {
type: 'number',
description: '거래 ID (cancel 시 필수)',
},
limit: {
type: 'number',
description: '조회할 거래 내역 수 (기본 10)',
},
},
required: ['action'],
},
},
};
export async function executeManageWallet(
args: ManageWalletArgs,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'balance':
return await getBalance(db, userId);
case 'account':
return getAccountInfo(env);
case 'request':
return await requestDeposit(db, userId, args.depositor_name, args.amount);
case 'history':
return await getHistory(db, userId, args.limit);
case 'cancel':
return await cancelTransaction(db, userId, args.transaction_id);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Wallet tool error', error as Error, { action: args.action });
return '지갑 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function getBalance(db?: D1Database, userId?: string): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const wallet = await db
.prepare('SELECT balance, currency FROM wallets WHERE user_id = ?')
.bind(user.id)
.first<{ balance: number; currency: string }>();
if (!wallet) {
return '예치금 계정이 없습니다. 입금 요청을 하시면 자동으로 생성됩니다.';
}
return `현재 잔액: ${wallet.balance.toLocaleString()}${wallet.currency}`;
}
function getAccountInfo(env?: Env): string {
const bankName = env?.DEPOSIT_BANK_NAME ?? '-';
const account = env?.DEPOSIT_BANK_ACCOUNT ?? '-';
const holder = env?.DEPOSIT_BANK_HOLDER ?? '-';
return [
'입금 계좌 안내',
`은행: ${bankName}`,
`계좌번호: ${account}`,
`예금주: ${holder}`,
'',
'입금 후 입금 요청을 등록해주시면 빠르게 확인해드립니다.',
].join('\n');
}
async function requestDeposit(
db?: D1Database,
userId?: string,
depositorName?: string,
amount?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!depositorName) return '입금자명을 입력해주세요.';
if (!amount || amount <= 0) return '올바른 금액을 입력해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
// Ensure wallet exists
await db
.prepare(
`INSERT INTO wallets (user_id, balance, currency)
VALUES (?, 0, 'KRW')
ON CONFLICT (user_id) DO NOTHING`
)
.bind(user.id)
.run();
// Create pending deposit transaction
const prefix = depositorName.length >= 2 ? depositorName.substring(0, 2) : depositorName;
const tx = await db
.prepare(
`INSERT INTO transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
VALUES (?, 'deposit', ?, 'pending', ?, ?, '입금 요청')
RETURNING id, created_at`
)
.bind(user.id, amount, depositorName, prefix)
.first<{ id: number; created_at: string }>();
if (!tx) return '입금 요청 등록에 실패했습니다.';
logger.info('Deposit request created', { txId: tx.id, userId, amount });
return [
'입금 요청이 등록되었습니다.',
`요청 번호: #${tx.id}`,
`금액: ${amount.toLocaleString()}`,
`입금자명: ${depositorName}`,
'',
'입금 확인 후 잔액에 반영됩니다.',
].join('\n');
}
async function getHistory(
db?: D1Database,
userId?: string,
limit?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const queryLimit = Math.min(limit ?? 10, 50);
const result = await db
.prepare(
`SELECT id, type, amount, status, description, created_at
FROM transactions WHERE user_id = ?
ORDER BY created_at DESC LIMIT ?`
)
.bind(user.id, queryLimit)
.all<Pick<Transaction, 'id' | 'type' | 'amount' | 'status' | 'description' | 'created_at'>>();
if (result.results.length === 0) {
return '거래 내역이 없습니다.';
}
const typeLabel: Record<string, string> = {
deposit: '입금',
withdrawal: '출금',
refund: '환불',
charge: '차감',
};
const statusLabel: Record<string, string> = {
pending: '대기',
confirmed: '완료',
rejected: '거부',
cancelled: '취소',
};
const lines = result.results.map((t) => {
const type = typeLabel[t.type] ?? t.type;
const status = statusLabel[t.status] ?? t.status;
const date = t.created_at.split('T')[0];
return `#${t.id} [${type}] ${t.amount.toLocaleString()}원 (${status}) ${date}`;
});
return `최근 거래 내역 (${result.results.length}건):\n${lines.join('\n')}`;
}
async function cancelTransaction(
db?: D1Database,
userId?: string,
transactionId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!transactionId) return '취소할 거래 번호를 입력해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const result = await db
.prepare(
`UPDATE transactions
SET status = 'cancelled'
WHERE id = ? AND user_id = ? AND status = 'pending'
RETURNING id`
)
.bind(transactionId, user.id)
.first<{ id: number }>();
if (!result) {
return '취소할 수 없는 거래입니다. 대기 중인 본인의 거래만 취소할 수 있습니다.';
}
logger.info('Transaction cancelled', { txId: transactionId, userId });
return `거래 #${transactionId}이(가) 취소되었습니다.`;
}

470
src/types.ts Normal file
View File

@@ -0,0 +1,470 @@
// ============================================
// Environment
// ============================================
export interface Env {
DB: D1Database;
AI: Ai;
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
OPENAI_API_KEY?: string;
ADMIN_TELEGRAM_IDS?: string;
ENVIRONMENT?: string;
// Financial
DEPOSIT_BANK_NAME?: string;
DEPOSIT_BANK_ACCOUNT?: string;
DEPOSIT_BANK_HOLDER?: string;
// API URLs
OPENAI_API_BASE?: string;
D2_RENDER_URL?: string;
NAMECHEAP_API_URL?: string;
WHOIS_API_URL?: string;
CLOUD_ORCHESTRATOR_URL?: string;
CLOUD_ORCHESTRATOR?: Fetcher;
// KV Namespaces
RATE_LIMIT_KV: KVNamespace;
SESSION_KV: KVNamespace;
CACHE_KV: KVNamespace;
// R2
R2_BUCKET: R2Bucket;
}
// ============================================
// Telegram Types
// ============================================
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
callback_query?: CallbackQuery;
}
export interface CallbackQuery {
id: string;
from: TelegramUser;
message?: TelegramMessage;
chat_instance: string;
data?: string;
}
export interface TelegramMessage {
message_id: number;
from: TelegramUser;
chat: TelegramChat;
date: number;
text?: string;
}
export interface TelegramUser {
id: number;
is_bot: boolean;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
}
export interface TelegramChat {
id: number;
type: string;
}
// ============================================
// OpenAI Types (Function Calling)
// ============================================
export interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: OpenAIToolCall[];
tool_call_id?: string;
name?: string;
}
export interface OpenAIChoice {
message: OpenAIMessage;
finish_reason: string;
}
export interface OpenAIAPIResponse {
choices: OpenAIChoice[];
}
export interface ToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
};
}
// ============================================
// Workers AI Types (Fallback)
// ============================================
export type WorkersAIModel =
| "@cf/meta/llama-3.1-8b-instruct"
| "@cf/meta/llama-3.2-3b-instruct";
export interface WorkersAIMessage {
role: "system" | "user" | "assistant";
content: string;
}
// ============================================
// User & Auth Types
// ============================================
export type UserRole = 'admin' | 'user';
export interface User {
id: number;
telegram_id: string;
username: string | null;
first_name: string | null;
role: UserRole;
language_code: string;
context_limit: number;
last_active_at: string | null;
is_blocked: number;
blocked_reason: string | null;
created_at: string;
updated_at: string;
}
// ============================================
// Financial Types
// ============================================
export interface Wallet {
id: number;
user_id: number;
balance: number;
currency: string;
version: number;
created_at: string;
updated_at: string;
}
export type TransactionType = 'deposit' | 'withdrawal' | 'refund' | 'charge';
export type TransactionStatus = 'pending' | 'confirmed' | 'rejected' | 'cancelled';
export interface Transaction {
id: number;
user_id: number;
type: TransactionType;
amount: number;
status: TransactionStatus;
depositor_name: string | null;
depositor_name_prefix: string | null;
description: string | null;
reference_type: string | null;
reference_id: number | null;
confirmed_by: number | null;
confirmed_at: string | null;
created_at: string;
}
export interface BankNotification {
bankName: string;
depositorName: string;
amount: number;
balanceAfter?: number;
transactionTime?: Date;
rawMessage: string;
}
// ============================================
// Asset Types
// ============================================
export type DomainStatus = 'active' | 'expired' | 'pending' | 'suspended';
export interface Domain {
id: number;
user_id: number;
domain: string;
status: DomainStatus;
registrar: string | null;
nameservers: string | null;
auto_renew: number;
expiry_date: string | null;
created_at: string;
updated_at: string;
}
export type ServerStatus = 'pending' | 'provisioning' | 'running' | 'stopped' | 'terminated' | 'failed';
export interface Server {
id: number;
user_id: number;
provider: string;
instance_id: string | null;
label: string | null;
ip_address: string | null;
region: string | null;
spec_label: string | null;
monthly_price: number | null;
status: ServerStatus;
image: string | null;
provisioned_at: string | null;
terminated_at: string | null;
expires_at: string | null;
created_at: string;
updated_at: string;
}
export interface DdosService {
id: number;
user_id: number;
target: string;
protection_level: 'basic' | 'standard' | 'premium';
status: 'active' | 'inactive' | 'suspended';
provider: string | null;
monthly_price: number | null;
expiry_date: string | null;
}
export interface VpnService {
id: number;
user_id: number;
protocol: 'wireguard' | 'openvpn' | 'ipsec';
status: 'active' | 'inactive' | 'suspended';
endpoint: string | null;
monthly_price: number | null;
expiry_date: string | null;
}
// ============================================
// Support Types
// ============================================
export interface Feedback {
id: number;
user_id: number;
session_type: string;
rating: number;
comment: string | null;
created_at: string;
}
export type PendingActionStatus = 'pending' | 'approved' | 'rejected' | 'executed' | 'failed';
export interface PendingAction {
id: number;
user_id: number;
action_type: string;
target: string;
params: string;
status: PendingActionStatus;
approved_by: number | null;
created_at: string;
executed_at: string | null;
}
export interface AuditLog {
id: number;
actor_id: number | null;
action: string;
resource_type: string;
resource_id: string | null;
details: string | null;
result: 'success' | 'failure';
request_id: string | null;
created_at: string;
}
export interface KnowledgeArticle {
id: number;
category: string;
title: string;
content: string;
tags: string | null;
language: string;
is_active: number;
created_at: string;
updated_at: string;
}
// ============================================
// Agent Session Types
// ============================================
export type OnboardingSessionStatus = 'greeting' | 'gathering' | 'suggesting' | 'completed';
export interface OnboardingSession {
user_id: string;
status: OnboardingSessionStatus;
collected_info: {
purpose?: string;
requirements?: string;
budget?: string;
tech_stack?: string[];
};
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
export type TroubleshootSessionStatus = 'gathering' | 'diagnosing' | 'suggesting' | 'escalated' | 'completed';
export interface TroubleshootSession {
user_id: string;
status: TroubleshootSessionStatus;
collected_info: {
category?: string;
symptoms?: string;
environment?: string;
errorMessage?: string;
affected_service?: string;
};
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
escalation_count: number;
created_at: number;
updated_at: number;
expires_at: number;
}
export type AssetSessionStatus = 'idle' | 'viewing' | 'managing' | 'completed';
export interface AssetSession {
user_id: string;
status: AssetSessionStatus;
collected_info: Record<string, unknown>;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
export type BillingSessionStatus = 'collecting_amount' | 'collecting_name' | 'confirming' | 'completed';
export interface BillingSession {
user_id: string;
status: BillingSessionStatus;
collected_info: {
amount?: number;
depositor_name?: string;
};
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
// ============================================
// Tool Argument Types
// ============================================
export interface ManageDomainArgs {
action: 'check' | 'whois' | 'list' | 'info' | 'set_ns' | 'price';
domain?: string;
nameservers?: string[];
tld?: string;
}
export interface ManageWalletArgs {
action: 'balance' | 'account' | 'request' | 'history' | 'cancel';
depositor_name?: string;
amount?: number;
transaction_id?: number;
limit?: number;
}
export interface ManageServerArgs {
action: 'list' | 'info' | 'start' | 'stop' | 'reboot';
server_id?: number;
}
export interface CheckServiceArgs {
action: 'status' | 'list';
service_type?: 'ddos' | 'vpn' | 'all';
service_id?: number;
}
export interface RenderD2Args {
source: string;
format?: 'svg' | 'png';
}
export interface AdminArgs {
action: 'block_user' | 'unblock_user' | 'set_role' | 'broadcast' | 'confirm_deposit' | 'reject_deposit' | 'list_pending';
target_user_id?: string;
role?: UserRole;
message?: string;
transaction_id?: number;
reason?: string;
}
export interface ApproveActionArgs {
action_id: number;
approve: boolean;
reason?: string;
}
// ============================================
// Inline Keyboard Data
// ============================================
export interface FeedbackKeyboardData {
type: 'feedback';
session_type: string;
rating: number;
}
export interface ActionApprovalKeyboardData {
type: 'action_approval';
action_id: number;
approve: boolean;
}
export interface EscalationKeyboardData {
type: 'escalation';
session_id: string;
action: 'accept' | 'reject';
}
export type KeyboardCallbackData =
| FeedbackKeyboardData
| ActionApprovalKeyboardData
| EscalationKeyboardData;
// ============================================
// D2 Rendering
// ============================================
export interface D2RenderRequest {
source: string;
format: 'svg' | 'png';
}
export interface D2RenderResponse {
success: boolean;
image?: ArrayBuffer;
error?: string;
}
// ============================================
// Request Context
// ============================================
export interface RequestContext {
requestId: string;
userId?: string;
startTime: number;
}

26
src/utils/api-urls.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { Env } from '../types';
const DEFAULT_OPENAI_GATEWAY = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai';
const DEFAULT_D2_RENDER_URL = 'http://10.253.100.107:8080';
/**
* OpenAI Chat Completions API URL (AI Gateway 경유)
*/
export function getOpenAIUrl(env: Env): string {
const base = env.OPENAI_API_BASE || DEFAULT_OPENAI_GATEWAY;
return `${base}/chat/completions`;
}
/**
* OpenAI API base URL (chat/completions 제외)
*/
export function getOpenAIBaseUrl(env: Env): string {
return env.OPENAI_API_BASE || DEFAULT_OPENAI_GATEWAY;
}
/**
* D2 diagram rendering service URL
*/
export function getD2RenderUrl(env: Env): string {
return env.D2_RENDER_URL || DEFAULT_D2_RENDER_URL;
}

View File

@@ -0,0 +1,208 @@
/**
* Circuit Breaker pattern implementation
*
* Prevents cascading failures by temporarily blocking requests
* to a failing service, giving it time to recover.
*/
import { metrics } from './metrics';
import { createLogger } from './logger';
const logger = createLogger('circuit-breaker');
export enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}
export interface CircuitBreakerOptions {
/** Number of consecutive failures before opening circuit (default: 5) */
failureThreshold?: number;
/** Time in ms to wait before attempting recovery (default: 60000) */
resetTimeoutMs?: number;
/** Time window in ms for monitoring failures (default: 120000) */
monitoringWindowMs?: number;
/** Service name for metrics (default: 'unknown') */
serviceName?: string;
}
export class CircuitBreakerError extends Error {
constructor(
message: string,
public readonly state: CircuitState
) {
super(message);
this.name = 'CircuitBreakerError';
}
}
interface FailureRecord {
timestamp: number;
error: Error;
}
export class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failures: FailureRecord[] = [];
private openedAt: number | null = null;
private successCount = 0;
private failureCount = 0;
private readonly failureThreshold: number;
private readonly resetTimeoutMs: number;
private readonly monitoringWindowMs: number;
private readonly serviceName: string;
constructor(options?: CircuitBreakerOptions) {
this.failureThreshold = options?.failureThreshold ?? 5;
this.resetTimeoutMs = options?.resetTimeoutMs ?? 60000;
this.monitoringWindowMs = options?.monitoringWindowMs ?? 120000;
this.serviceName = options?.serviceName ?? 'unknown';
logger.info('Initialized', {
serviceName: this.serviceName,
failureThreshold: this.failureThreshold,
resetTimeoutMs: this.resetTimeoutMs,
monitoringWindowMs: this.monitoringWindowMs,
});
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
getState(): CircuitState {
return this.state;
}
getStats() {
const lastFailure = this.failures.length > 0
? this.failures[this.failures.length - 1]
: null;
return {
state: this.state,
failures: this.failures.length,
lastFailureTime: lastFailure ? new Date(lastFailure.timestamp) : undefined,
stats: {
totalRequests: this.successCount + this.failureCount,
totalFailures: this.failureCount,
totalSuccesses: this.successCount,
},
config: {
failureThreshold: this.failureThreshold,
resetTimeoutMs: this.resetTimeoutMs,
monitoringWindowMs: this.monitoringWindowMs,
},
};
}
reset(): void {
logger.info('Manual reset', { service: this.serviceName });
this.state = CircuitState.CLOSED;
this.failures = [];
this.openedAt = null;
this.successCount = 0;
this.failureCount = 0;
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
private cleanupOldFailures(): void {
const cutoff = Date.now() - this.monitoringWindowMs;
this.failures = this.failures.filter(record => record.timestamp > cutoff);
}
private checkResetTimeout(): void {
if (this.state === CircuitState.OPEN && this.openedAt !== null) {
const elapsed = Date.now() - this.openedAt;
if (elapsed >= this.resetTimeoutMs) {
logger.info('Reset timeout reached, transitioning to HALF_OPEN', {
service: this.serviceName,
elapsedMs: elapsed
});
this.state = CircuitState.HALF_OPEN;
metrics.record('circuit_breaker_state', 2, { service: this.serviceName });
}
}
}
private onSuccess(): void {
this.successCount++;
if (this.state === CircuitState.HALF_OPEN) {
logger.info('Half-open test succeeded, closing circuit', {
service: this.serviceName
});
this.state = CircuitState.CLOSED;
this.failures = [];
this.openedAt = null;
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
}
private onFailure(error: Error): void {
this.failureCount++;
const now = Date.now();
this.failures.push({ timestamp: now, error });
this.cleanupOldFailures();
if (this.state === CircuitState.HALF_OPEN) {
logger.warn('Half-open test failed, reopening circuit', {
service: this.serviceName,
error: error.message
});
this.state = CircuitState.OPEN;
this.openedAt = now;
metrics.record('circuit_breaker_state', 1, { service: this.serviceName });
return;
}
if (this.state === CircuitState.CLOSED) {
if (this.failures.length >= this.failureThreshold) {
logger.warn('Failure threshold exceeded, opening circuit', {
service: this.serviceName,
failureThreshold: this.failureThreshold,
currentFailures: this.failures.length
});
this.state = CircuitState.OPEN;
this.openedAt = now;
metrics.record('circuit_breaker_state', 1, { service: this.serviceName });
}
}
}
/**
* Execute a function through the circuit breaker
*/
async execute<T>(fn: () => Promise<T>): Promise<T> {
this.checkResetTimeout();
if (this.state === CircuitState.OPEN) {
logger.warn('Request blocked - circuit is OPEN', {
service: this.serviceName
});
throw new CircuitBreakerError(
'Circuit breaker is open - service unavailable',
this.state
);
}
metrics.increment('api_call_count', { service: this.serviceName });
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
metrics.increment('api_error_count', { service: this.serviceName });
const err = error instanceof Error ? error : new Error(String(error));
this.onFailure(err);
logger.error('Operation failed', err, {
service: this.serviceName,
failures: this.failures.length,
threshold: this.failureThreshold
});
throw err;
}
}
}

View File

@@ -0,0 +1,97 @@
import { z } from 'zod/v4';
import { createLogger } from './logger';
const logger = createLogger('env-validation');
/**
* Environment variable schema with validation rules
*/
export const EnvSchema = z.object({
// Required secrets
BOT_TOKEN: z.string().min(1, 'BOT_TOKEN is required'),
WEBHOOK_SECRET: z.string().min(10, 'WEBHOOK_SECRET must be at least 10 characters'),
// Optional secrets
OPENAI_API_KEY: z.string().optional(),
ADMIN_TELEGRAM_IDS: z.string().optional(),
// Financial config (optional)
DEPOSIT_BANK_NAME: z.string().optional(),
DEPOSIT_BANK_ACCOUNT: z.string().optional(),
DEPOSIT_BANK_HOLDER: z.string().optional(),
// Configuration with defaults
ENVIRONMENT: z.enum(['development', 'production']).default('production'),
// API URLs (optional, have defaults in wrangler.toml)
OPENAI_API_BASE: z.string().url().optional(),
D2_RENDER_URL: z.string().url().optional(),
NAMECHEAP_API_URL: z.string().url().optional(),
WHOIS_API_URL: z.string().url().optional(),
CLOUD_ORCHESTRATOR_URL: z.string().url().optional(),
});
export type ValidatedEnv = z.infer<typeof EnvSchema>;
export interface EnvValidationResult {
success: boolean;
errors: string[];
warnings: string[];
}
/**
* Validate environment variables
* Call this early in worker initialization
*/
export function validateEnv(env: Record<string, unknown>): EnvValidationResult {
const result: EnvValidationResult = {
success: true,
errors: [],
warnings: [],
};
const parsed = EnvSchema.safeParse(env);
if (!parsed.success) {
result.success = false;
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
result.errors.push(`${path}: ${issue.message}`);
}
}
// Warnings for recommended but optional vars
if (!env.OPENAI_API_KEY) {
result.warnings.push('OPENAI_API_KEY not set - will use Workers AI fallback');
}
if (!env.ADMIN_TELEGRAM_IDS) {
result.warnings.push('ADMIN_TELEGRAM_IDS not set - admin notifications disabled');
}
if (!env.DEPOSIT_BANK_NAME || !env.DEPOSIT_BANK_ACCOUNT) {
result.warnings.push('Bank deposit info not fully configured - deposit feature limited');
}
if (result.errors.length > 0) {
logger.error('Environment validation failed', new Error('Invalid configuration'), {
errors: result.errors,
});
}
if (result.warnings.length > 0) {
logger.warn('Environment validation warnings', { warnings: result.warnings });
}
return result;
}
/**
* Quick check for critical env vars - throws on failure
*/
export function requireEnv(env: Record<string, unknown>, keys: string[]): void {
const missing = keys.filter(key => !env[key]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
}

236
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,236 @@
/**
* 구조화된 로깅 유틸리티
*
* Cloudflare Workers 환경에서 JSON 기반 로깅을 지원하며,
* 프로덕션 환경에서는 구조화된 JSON 로그를,
* 개발 환경에서는 읽기 쉬운 포맷을 제공합니다.
*
* @module logger
*/
import type { Env } from '../types';
/**
* 로그 레벨 열거형
*
* 우선순위: DEBUG < INFO < WARN < ERROR < FATAL
*/
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
FATAL = 'FATAL',
}
export type LogContextValue =
| string
| number
| boolean
| null
| undefined
| unknown
| LogContextValue[]
| { [key: string]: unknown };
export type LogContext = Record<string, unknown>;
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
service?: string;
context?: LogContext;
error?: {
name: string;
message: string;
stack?: string;
};
userId?: string;
duration?: number;
}
export interface LoggerOptions {
minLevel?: LogLevel;
environment?: 'production' | 'development';
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARN]: 2,
[LogLevel.ERROR]: 3,
[LogLevel.FATAL]: 4,
};
const LOG_LEVEL_EMOJI: Record<LogLevel, string> = {
[LogLevel.DEBUG]: '🔍',
[LogLevel.INFO]: '',
[LogLevel.WARN]: '⚠️',
[LogLevel.ERROR]: '❌',
[LogLevel.FATAL]: '💀',
};
export class Logger {
private minLevel: LogLevel;
private isProduction: boolean;
constructor(
private service: string,
options: LoggerOptions = {}
) {
this.minLevel = options.minLevel || LogLevel.INFO;
this.isProduction = options.environment === 'production';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.minLevel];
}
private write(entry: LogEntry): void {
try {
if (this.isProduction) {
console.log(JSON.stringify(entry));
} else {
const emoji = LOG_LEVEL_EMOJI[entry.level];
const timestamp = entry.timestamp;
const level = (entry.level as string).padEnd(5);
const service = entry.service ? `[${entry.service}]` : '';
const message = entry.message;
let output = `${emoji} [${timestamp}] ${level} ${service} ${message}`;
if (entry.context && Object.keys(entry.context).length > 0) {
output += ` ${JSON.stringify(entry.context)}`;
}
if (entry.error) {
output += `\n Error: ${entry.error.name}: ${entry.error.message}`;
if (entry.error.stack) {
output += `\n Stack: ${entry.error.stack}`;
}
}
if (entry.duration !== undefined) {
output += ` (${entry.duration}ms)`;
}
console.log(output);
}
} catch (error) {
console.error('[Logger] Failed to write log:', error);
console.error('[Logger] Original log entry:', entry);
}
}
private createEntry(
level: LogLevel,
message: string,
context?: LogContext,
error?: Error
): LogEntry {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
service: this.service,
};
if (context) {
entry.context = context;
}
if (error) {
entry.error = {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return entry;
}
debug(message: string, context?: LogContext): void {
if (!this.shouldLog(LogLevel.DEBUG)) return;
this.write(this.createEntry(LogLevel.DEBUG, message, context));
}
info(message: string, context?: LogContext): void {
if (!this.shouldLog(LogLevel.INFO)) return;
this.write(this.createEntry(LogLevel.INFO, message, context));
}
warn(message: string, context?: LogContext): void {
if (!this.shouldLog(LogLevel.WARN)) return;
this.write(this.createEntry(LogLevel.WARN, message, context));
}
error(message: string, error?: Error, context?: LogContext): void {
if (!this.shouldLog(LogLevel.ERROR)) return;
this.write(this.createEntry(LogLevel.ERROR, message, context, error));
}
fatal(message: string, error?: Error, context?: LogContext): void {
if (!this.shouldLog(LogLevel.FATAL)) return;
this.write(this.createEntry(LogLevel.FATAL, message, context, error));
}
startTimer(message?: string, context?: LogContext): () => void {
const startTime = Date.now();
const timerMessage = message || 'Operation completed';
return () => {
const duration = Date.now() - startTime;
const entry = this.createEntry(LogLevel.INFO, timerMessage, context);
entry.duration = duration;
this.write(entry);
};
}
withUser(userId: string): Logger {
const userLogger = new Logger(this.service, {
minLevel: this.minLevel,
environment: this.isProduction ? 'production' : 'development',
});
const originalWrite = userLogger['write'].bind(userLogger);
userLogger['write'] = (entry: LogEntry) => {
entry.userId = userId;
originalWrite(entry);
};
return userLogger;
}
}
export function createLogger(service: string, env?: Partial<Env>): Logger {
const environment =
env && 'ENVIRONMENT' in env && env.ENVIRONMENT === 'production'
? 'production'
: 'development';
return new Logger(service, {
minLevel: LogLevel.INFO,
environment,
});
}
export function createDebugLogger(service: string): Logger {
return new Logger(service, {
minLevel: LogLevel.DEBUG,
environment: 'development',
});
}
/**
* Mask sensitive user ID for GDPR compliance
*
* Shows first 4 characters only, rest replaced with asterisks.
*/
export function maskUserId(userId: string | number | undefined): string {
if (!userId) return 'unknown';
const str = String(userId);
if (str.length <= 4) return '****';
return str.slice(0, 4) + '****';
}

109
src/utils/metrics.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* 메트릭 수집 시스템
*
* API 호출 성능, Circuit Breaker 상태, 에러율 등을 추적하는 메트릭 시스템
* - 메모리 기반 (최근 1000개 메트릭만 유지)
* - Worker 재시작 시 초기화
*/
export type MetricType =
| 'api_call_duration'
| 'api_call_count'
| 'api_error_count'
| 'circuit_breaker_state'
| 'retry_count'
| 'cache_hit_rate';
export interface Metric {
name: MetricType;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
export interface MetricStats {
count: number;
sum: number;
avg: number;
min: number;
max: number;
}
export class MetricsCollector {
private metrics: Metric[] = [];
private readonly maxMetrics = 1000;
increment(metric: MetricType, tags?: Record<string, string>): void {
this.record(metric, 1, tags);
}
record(metric: MetricType, value: number, tags?: Record<string, string>): void {
this.metrics.push({
name: metric,
value,
timestamp: Date.now(),
tags,
});
if (this.metrics.length > this.maxMetrics) {
this.metrics.shift();
}
}
startTimer(metric: MetricType, tags?: Record<string, string>): () => void {
const startTime = Date.now();
return () => {
const duration = Date.now() - startTime;
this.record(metric, duration, tags);
};
}
getMetrics(since?: number): Metric[] {
if (since === undefined) {
return [...this.metrics];
}
return this.metrics.filter(m => m.timestamp >= since);
}
getStats(metric: MetricType, tags?: Record<string, string>): MetricStats {
let filtered = this.metrics.filter(m => m.name === metric);
if (tags) {
filtered = filtered.filter(m => {
if (!m.tags) return false;
for (const key in tags) {
if (tags[key] !== m.tags[key]) {
return false;
}
}
return true;
});
}
if (filtered.length === 0) {
return { count: 0, sum: 0, avg: 0, min: 0, max: 0 };
}
const values = filtered.map(m => m.value);
const sum = values.reduce((a, b) => a + b, 0);
const count = values.length;
const avg = sum / count;
const min = Math.min(...values);
const max = Math.max(...values);
return { count, sum, avg, min, max };
}
reset(): void {
this.metrics = [];
}
size(): number {
return this.metrics.length;
}
}
/**
* 전역 메트릭 인스턴스
*/
export const metrics = new MetricsCollector();

View File

@@ -0,0 +1,85 @@
/**
* Optimistic Locking Utility
*
* Prevents data inconsistencies in financial operations where D1 batch()
* is not a true transaction and partial failures can occur.
*
* Pattern:
* 1. Read current version from wallets
* 2. Perform operations
* 3. UPDATE with version check (WHERE version = ?)
* 4. If version mismatch (changes = 0), throw OptimisticLockError
* 5. Retry with exponential backoff (max 3 attempts)
*/
import { createLogger } from './logger';
const logger = createLogger('optimistic-lock');
interface ErrorWithCapture {
captureStackTrace?: (target: object, constructor?: Function) => void;
}
export class OptimisticLockError extends Error {
constructor(message: string) {
super(message);
this.name = 'OptimisticLockError';
const ErrorConstructor = Error as unknown as ErrorWithCapture;
if (typeof ErrorConstructor.captureStackTrace === 'function') {
ErrorConstructor.captureStackTrace(this, OptimisticLockError);
}
}
}
/**
* Execute operation with optimistic locking and automatic retry
*
* @param _db - D1 Database instance
* @param operation - Async operation to execute (receives attempt number)
* @param maxRetries - Maximum retry attempts (default: 3)
* @returns Promise resolving to operation result
* @throws Error if all retries exhausted or non-OptimisticLockError occurs
*/
export async function executeWithOptimisticLock<T>(
_db: D1Database,
operation: (attempt: number) => Promise<T>,
maxRetries: number = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
logger.info(`Optimistic lock attempt ${attempt}/${maxRetries}`, { attempt });
const result = await operation(attempt);
if (attempt > 1) {
logger.info('Optimistic lock succeeded after retry', { attempt, retriesNeeded: attempt - 1 });
}
return result;
} catch (error) {
if (!(error instanceof OptimisticLockError)) {
logger.error('Non-optimistic-lock error in operation', error as Error, { attempt });
throw error;
}
if (attempt < maxRetries) {
// Exponential backoff: 100ms, 200ms, 400ms
const delayMs = 100 * Math.pow(2, attempt - 1);
logger.warn('Optimistic lock conflict - retrying', {
attempt,
nextRetryIn: `${delayMs}ms`,
error: error.message,
});
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
logger.error('Optimistic lock failed - max retries exhausted', error, {
maxRetries,
finalAttempt: attempt,
});
}
}
}
throw new Error(
`처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (${maxRetries}회 재시도 실패)`
);
}

54
src/utils/patterns.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Centralized pattern detection for keyword matching
*
* Used for:
* - Tool category detection (routing to correct agent)
* - Message classification
*/
// ============================================================================
// Tool Category Patterns
// ============================================================================
export const DOMAIN_PATTERNS = /도메인|네임서버|whois|dns|tld|도메인.*등록|등록.*도메인|nameserver|\b\w+\.(com|net|io|kr|org)\b/i;
export const BILLING_PATTERNS = /입금|충전|잔액|계좌|예치금|송금|돈|결제|요금|payment|billing|wallet|credit|deposit|balance|환불|미납|청구/i;
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr|\d+번\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|#\d+\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|reboot|server/i;
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨|error|problem|down|slow|timeout|crash|접속.*안|연결.*안|불안정/i;
export const ONBOARDING_PATTERNS = /신규|가입|서비스\s*소개|뭐하는|어떤\s*서비스|요금|플랜|가격|plan|pricing|시작하려|처음|어떻게\s*이용|회원가입|시작/i;
export const SECURITY_PATTERNS = /ddos|DDoS|디도스|vpn|VPN|보안|방어|공격|트래픽\s*폭주|서비스\s*마비|봇\s*공격|대역폭\s*공격|방화벽|firewall|ssl|인증서/i;
export const ASSET_PATTERNS = /자산|현황|대시보드|내\s*서버|내\s*도메인|보유|목록|리스트|내\s*서비스|내\s*계정|asset|my\s*server|my\s*domain/i;
// ============================================================================
// Pattern Matching Functions
// ============================================================================
/**
* Check if text matches a given pattern
*/
export function matchesPattern(text: string, pattern: RegExp): boolean {
return pattern.test(text);
}
/**
* Detect tool categories from message text
* @returns Array of matched category strings
*/
export function detectToolCategories(text: string): string[] {
const categories: string[] = [];
if (DOMAIN_PATTERNS.test(text)) categories.push('domain');
if (BILLING_PATTERNS.test(text)) categories.push('billing');
if (SERVER_PATTERNS.test(text)) categories.push('server');
if (TROUBLESHOOT_PATTERNS.test(text)) categories.push('troubleshoot');
if (ONBOARDING_PATTERNS.test(text)) categories.push('onboarding');
if (SECURITY_PATTERNS.test(text)) categories.push('security');
if (ASSET_PATTERNS.test(text)) categories.push('asset');
return categories;
}

144
src/utils/retry.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* Retry utility with exponential backoff and jitter
*/
import { metrics } from './metrics';
import { createLogger } from './logger';
const logger = createLogger('retry');
export interface RetryOptions {
/** Maximum number of retry attempts (default: 3) */
maxRetries?: number;
/** Initial delay in milliseconds before first retry (default: 1000) */
initialDelayMs?: number;
/** Maximum delay cap in milliseconds (default: 10000) */
maxDelayMs?: number;
/** Multiplier for exponential backoff (default: 2) */
backoffMultiplier?: number;
/** Whether to add random jitter to delays (default: true) */
jitter?: boolean;
/** Service name for metrics tracking (optional) */
serviceName?: string;
}
export class RetryError extends Error {
constructor(
message: string,
public readonly attempts: number,
public readonly lastError: Error
) {
super(message);
this.name = 'RetryError';
}
}
function calculateDelay(
attempt: number,
initialDelay: number,
maxDelay: number,
multiplier: number,
useJitter: boolean
): number {
let delay = initialDelay * Math.pow(multiplier, attempt);
delay = Math.min(delay, maxDelay);
if (useJitter) {
const jitterRange = delay * 0.2;
const jitterAmount = Math.random() * jitterRange * 2 - jitterRange;
delay += jitterAmount;
}
return Math.floor(delay);
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Execute a function with retry logic using exponential backoff
*
* @param fn - Async function to execute
* @param options - Retry configuration options
* @returns Promise resolving to the function's result
* @throws RetryError if all attempts fail
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options?: RetryOptions
): Promise<T> {
const {
maxRetries = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
backoffMultiplier = 2,
jitter = true,
serviceName = 'unknown',
} = options || {};
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await fn();
if (attempt > 0) {
logger.info('Success on retry', {
service: serviceName,
attempt: attempt + 1,
totalAttempts: maxRetries + 1
});
}
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxRetries) {
logger.error('All attempts failed', lastError, {
service: serviceName,
totalAttempts: maxRetries + 1
});
throw new RetryError(
`Operation failed after ${maxRetries + 1} attempts: ${lastError.message}`,
maxRetries + 1,
lastError
);
}
if (attempt > 0) {
metrics.increment('retry_count', {
service: serviceName,
attempt: String(attempt),
});
}
const delay = calculateDelay(
attempt,
initialDelayMs,
maxDelayMs,
backoffMultiplier,
jitter
);
logger.warn('Attempt failed, retrying', {
service: serviceName,
attempt: attempt + 1,
totalAttempts: maxRetries + 1,
delayMs: delay,
error: lastError.message
});
await sleep(delay);
}
}
// TypeScript safety: should never be reached
throw new RetryError(
'Unexpected retry logic error',
maxRetries + 1,
lastError!
);
}

View File

@@ -0,0 +1,231 @@
/**
* Session Manager - Generic session CRUD for agents
*
* Eliminates duplicated session management code across agents:
* - Onboarding Agent
* - Troubleshoot Agent
* - Asset Agent
* - Billing Agent
*/
import { createLogger } from './logger';
const logger = createLogger('session-manager');
/**
* Base interface for all agent sessions
*/
export interface BaseSession {
user_id: string;
status: string;
collected_info: Record<string, unknown>;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
export interface SessionManagerConfig {
tableName: string;
ttlMs: number;
maxMessages: number;
}
/**
* Generic session manager for all agents
* Provides CRUD operations, expiry checking, and message management
*/
export class SessionManager<T extends BaseSession> {
private readonly config: SessionManagerConfig;
constructor(config: SessionManagerConfig) {
this.config = config;
}
/**
* Get session from D1 database
*/
async get(db: D1Database, userId: string): Promise<T | null> {
try {
const now = Date.now();
const result = await db.prepare(
`SELECT * FROM ${this.config.tableName} WHERE user_id = ? AND expires_at > ?`
).bind(userId, now).first();
if (!result) {
return null;
}
const session: T = {
user_id: result.user_id as string,
status: result.status as string,
collected_info: result.collected_info
? JSON.parse(result.collected_info as string)
: {},
messages: result.messages
? JSON.parse(result.messages as string)
: [],
created_at: result.created_at as number,
updated_at: result.updated_at as number,
expires_at: result.expires_at as number,
...this.parseAdditionalFields(result),
} as T;
logger.info('세션 조회 성공', {
userId,
status: session.status,
tableName: this.config.tableName
});
return session;
} catch (error) {
logger.error('세션 조회 실패', error as Error, { userId, tableName: this.config.tableName });
return null;
}
}
/**
* Save session to D1 database (insert or replace)
*/
async save(db: D1Database, session: T): Promise<void> {
try {
const now = Date.now();
session.updated_at = now;
session.expires_at = now + this.config.ttlMs;
const additionalColumns = this.getAdditionalColumns(session);
const additionalColumnNames = Object.keys(additionalColumns);
const additionalColumnValues = Object.values(additionalColumns);
const baseColumns = ['user_id', 'status', 'collected_info', 'messages', 'created_at', 'updated_at', 'expires_at'];
const allColumns = [...baseColumns, ...additionalColumnNames];
const allPlaceholders = [...Array(baseColumns.length).fill('?'), ...Array(additionalColumnNames.length).fill('?')];
const sql = `
INSERT INTO ${this.config.tableName}
(${allColumns.join(', ')})
VALUES (${allPlaceholders.join(', ')})
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
${additionalColumnNames.length > 0 ? ', ' + additionalColumnNames.map(col => `${col} = excluded.${col}`).join(', ') : ''}
`;
const baseValues = [
session.user_id,
session.status,
JSON.stringify(session.collected_info || {}),
JSON.stringify(session.messages || []),
session.created_at || now,
now,
session.expires_at
];
await db.prepare(sql).bind(...baseValues, ...additionalColumnValues).run();
logger.info('세션 저장 성공', {
userId: session.user_id,
status: session.status,
tableName: this.config.tableName
});
} catch (error) {
logger.error('세션 저장 실패', error as Error, { userId: session.user_id, tableName: this.config.tableName });
throw error;
}
}
/**
* Delete session from D1 database
*/
async delete(db: D1Database, userId: string): Promise<void> {
try {
await db.prepare(
`DELETE FROM ${this.config.tableName} WHERE user_id = ?`
).bind(userId).run();
logger.info('세션 삭제 성공', { userId, tableName: this.config.tableName });
} catch (error) {
logger.error('세션 삭제 실패', error as Error, { userId, tableName: this.config.tableName });
}
}
/**
* Check if session exists (without full load)
*/
async has(db: D1Database, userId: string): Promise<boolean> {
try {
const now = Date.now();
const result = await db.prepare(
`SELECT expires_at FROM ${this.config.tableName} WHERE user_id = ? AND expires_at > ?`
).bind(userId, now).first();
return result !== null;
} catch (error) {
logger.error('세션 존재 확인 실패', error as Error, { userId, tableName: this.config.tableName });
return false;
}
}
/**
* Create a new session object
*/
create(userId: string, status: string, additionalFields?: Partial<T>): T {
const now = Date.now();
return {
user_id: userId,
status,
collected_info: {},
messages: [],
created_at: now,
updated_at: now,
expires_at: now + this.config.ttlMs,
...additionalFields,
} as T;
}
isExpired(session: T): boolean {
return session.expires_at < Date.now();
}
/**
* Add message to session with max limit
*/
addMessage(session: T, role: 'user' | 'assistant', content: string): void {
session.messages.push({ role, content });
if (session.messages.length > this.config.maxMessages) {
session.messages = session.messages.slice(-this.config.maxMessages);
logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', {
userId: session.user_id,
maxMessages: this.config.maxMessages,
tableName: this.config.tableName
});
}
}
/**
* Get or create session (convenience method)
*/
async getOrCreate(db: D1Database, userId: string, initialStatus: string): Promise<T> {
const existing = await this.get(db, userId);
if (existing) return existing;
return this.create(userId, initialStatus);
}
/**
* Override in subclasses to parse additional fields from DB result
*/
protected parseAdditionalFields(_result: Record<string, unknown>): Partial<T> {
return {};
}
/**
* Override in subclasses to provide additional columns for saving
*/
protected getAdditionalColumns(_session: T): Record<string, unknown> {
return {};
}
}

60
tests/security.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { timingSafeEqual, isAdmin } from '../src/security';
describe('timingSafeEqual', () => {
it('returns true for equal strings', () => {
expect(timingSafeEqual('abc123', 'abc123')).toBe(true);
expect(timingSafeEqual('secret-token', 'secret-token')).toBe(true);
});
it('returns false for different strings', () => {
expect(timingSafeEqual('abc123', 'abc124')).toBe(false);
expect(timingSafeEqual('short', 'longer')).toBe(false);
});
it('returns false for null/undefined', () => {
expect(timingSafeEqual(null, 'abc')).toBe(false);
expect(timingSafeEqual('abc', null)).toBe(false);
expect(timingSafeEqual(null, null)).toBe(false);
expect(timingSafeEqual(undefined, 'abc')).toBe(false);
expect(timingSafeEqual('abc', undefined)).toBe(false);
expect(timingSafeEqual(undefined, undefined)).toBe(false);
});
it('returns false for empty string vs non-empty', () => {
expect(timingSafeEqual('', 'abc')).toBe(false);
expect(timingSafeEqual('abc', '')).toBe(false);
});
});
describe('isAdmin', () => {
const adminIds = '123456,789012,345678';
it('returns true for admin IDs', () => {
expect(isAdmin('123456', adminIds)).toBe(true);
expect(isAdmin('789012', adminIds)).toBe(true);
expect(isAdmin('345678', adminIds)).toBe(true);
});
it('returns true for numeric admin ID', () => {
expect(isAdmin(123456, adminIds)).toBe(true);
});
it('returns false for non-admin IDs', () => {
expect(isAdmin('999999', adminIds)).toBe(false);
expect(isAdmin('000000', adminIds)).toBe(false);
});
it('returns false when adminIds is undefined', () => {
expect(isAdmin('123456', undefined)).toBe(false);
});
it('returns false when adminIds is empty', () => {
expect(isAdmin('123456', '')).toBe(false);
});
it('handles whitespace in admin ID list', () => {
expect(isAdmin('123', '123, 456, 789')).toBe(true);
expect(isAdmin('456', '123, 456, 789')).toBe(true);
});
});

120
tests/setup.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* Vitest test setup
*
* Miniflare-based D1 + KV simulation for integration tests.
*/
import { readFileSync } from 'fs';
import { join } from 'path';
import { beforeAll, afterEach } from 'vitest';
import { Miniflare } from 'miniflare';
let mf: Miniflare | null = null;
let db: D1Database | null = null;
declare global {
var getMiniflareBindings: () => {
DB: D1Database;
RATE_LIMIT_KV: KVNamespace;
SESSION_KV: KVNamespace;
CACHE_KV: KVNamespace;
};
}
beforeAll(async () => {
mf = new Miniflare({
modules: true,
script: 'export default { fetch() { return new Response("test"); } }',
d1Databases: {
DB: '__test_db__',
},
kvNamespaces: ['RATE_LIMIT_KV', 'SESSION_KV', 'CACHE_KV'],
});
db = await mf.getD1Database('DB');
(global as any).getMiniflareBindings = () => ({
DB: db as D1Database,
RATE_LIMIT_KV: {} as KVNamespace,
SESSION_KV: {} as KVNamespace,
CACHE_KV: {} as KVNamespace,
});
// Schema initialization
const schemaPath = join(__dirname, '../schema.sql');
const schema = readFileSync(schemaPath, 'utf-8');
const cleanSchema = schema
.split('\n')
.filter(line => !line.trim().startsWith('--'))
.join('\n');
const statements = cleanSchema
.split(';')
.map(s => s.replace(/\s+/g, ' ').trim())
.filter(s => s.length > 0);
try {
for (const statement of statements) {
await db.exec(statement + ';');
}
} catch (error) {
console.error('Schema initialization failed:', error);
throw error;
}
});
afterEach(async () => {
if (!db) return;
// Delete child tables first, then parent tables (FK order)
// 1. No FK dependencies between each other
await db.exec('DELETE FROM feedback');
await db.exec('DELETE FROM pending_actions');
await db.exec('DELETE FROM audit_logs');
// 2. bank_notifications refs transactions
await db.exec('DELETE FROM bank_notifications');
// 3. transactions refs users
await db.exec('DELETE FROM transactions');
// 4. wallets refs users
await db.exec('DELETE FROM wallets');
// 5. Asset tables ref users
await db.exec('DELETE FROM domains');
await db.exec('DELETE FROM servers');
await db.exec('DELETE FROM services_ddos');
await db.exec('DELETE FROM services_vpn');
// 6. Conversation tables ref users
await db.exec('DELETE FROM conversations');
await db.exec('DELETE FROM conversation_archives');
// 7. Standalone tables (no FKs)
await db.exec('DELETE FROM knowledge_articles');
await db.exec('DELETE FROM d2_cache');
// 8. Session tables (no FKs to users)
await db.exec('DELETE FROM onboarding_sessions');
await db.exec('DELETE FROM troubleshoot_sessions');
await db.exec('DELETE FROM asset_sessions');
await db.exec('DELETE FROM billing_sessions');
// 9. users last (parent table)
await db.exec('DELETE FROM users');
});
/**
* Create a test user and return its auto-incremented id.
*/
export async function createTestUser(
telegramId: string,
username?: string
): Promise<number> {
const bindings = getMiniflareBindings();
const result = await bindings.DB.prepare(
'INSERT INTO users (telegram_id, username) VALUES (?, ?)'
).bind(telegramId, username || null).run();
return Number(result.meta?.last_row_id || 0);
}
/**
* Get the test D1Database binding.
*/
export function getTestDB(): D1Database {
return getMiniflareBindings().DB;
}

43
tests/tools/index.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { selectToolsForMessage, executeTool } from '../../src/tools/index';
describe('selectToolsForMessage', () => {
it('returns knowledge tool for unknown/generic patterns', () => {
const tools = selectToolsForMessage('안녕하세요');
expect(tools).toHaveLength(1);
expect(tools[0].function.name).toBe('search_knowledge');
});
it('returns domain tool for domain-related messages', () => {
const tools = selectToolsForMessage('도메인 등록하고 싶어요');
const names = tools.map(t => t.function.name);
expect(names).toContain('manage_domain');
expect(names).toContain('search_knowledge');
});
it('returns wallet tool for billing messages', () => {
const tools = selectToolsForMessage('잔액 확인해주세요');
const names = tools.map(t => t.function.name);
expect(names).toContain('manage_wallet');
expect(names).toContain('search_knowledge');
});
it('returns server tool for server-related messages', () => {
const tools = selectToolsForMessage('서버 목록 보여줘');
const names = tools.map(t => t.function.name);
expect(names).toContain('manage_server');
});
it('returns security tools for DDoS/VPN messages', () => {
const tools = selectToolsForMessage('DDoS 방어 서비스 현황');
const names = tools.map(t => t.function.name);
expect(names).toContain('check_service');
});
});
describe('executeTool', () => {
it('returns error message for unknown tool name', async () => {
const result = await executeTool('nonexistent_tool', {});
expect(result).toContain('알 수 없는 도구');
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { CircuitBreaker, CircuitBreakerError, CircuitState } from '../../src/utils/circuit-breaker';
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('CircuitBreaker', () => {
it('starts in CLOSED state', () => {
const cb = new CircuitBreaker({ serviceName: 'test' });
expect(cb.getState()).toBe(CircuitState.CLOSED);
});
it('passes through successful executions', async () => {
const cb = new CircuitBreaker({ serviceName: 'test' });
const result = await cb.execute(async () => 'ok');
expect(result).toBe('ok');
expect(cb.getState()).toBe(CircuitState.CLOSED);
});
it('opens after failure threshold is exceeded', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 3,
resetTimeoutMs: 100,
});
for (let i = 0; i < 3; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');
}
expect(cb.getState()).toBe(CircuitState.OPEN);
});
it('rejects requests while OPEN', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 5000,
});
// Trip the breaker
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
expect(cb.getState()).toBe(CircuitState.OPEN);
// Should throw CircuitBreakerError
await expect(cb.execute(async () => 'ok')).rejects.toThrow(CircuitBreakerError);
});
it('transitions to HALF_OPEN after reset timeout', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 100,
});
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
expect(cb.getState()).toBe(CircuitState.OPEN);
await sleep(150);
// Next execute call checks the timeout and transitions to HALF_OPEN
const result = await cb.execute(async () => 'recovered');
expect(result).toBe('recovered');
// Successful test in HALF_OPEN closes the circuit
expect(cb.getState()).toBe(CircuitState.CLOSED);
});
it('closes after successful test in HALF_OPEN', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 100,
});
// Open the circuit
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
expect(cb.getState()).toBe(CircuitState.OPEN);
// Wait for reset timeout
await sleep(150);
// Successful execution transitions HALF_OPEN -> CLOSED
await cb.execute(async () => 'success');
expect(cb.getState()).toBe(CircuitState.CLOSED);
// Verify circuit is fully operational again
const result = await cb.execute(async () => 'working');
expect(result).toBe('working');
});
it('re-opens if HALF_OPEN test fails', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 100,
});
// Open the circuit
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
await sleep(150);
// Fail during HALF_OPEN
await expect(cb.execute(async () => { throw new Error('still broken'); })).rejects.toThrow();
expect(cb.getState()).toBe(CircuitState.OPEN);
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { createLogger, Logger, maskUserId } from '../../src/utils/logger';
describe('Logger', () => {
it('createLogger returns a Logger instance', () => {
const logger = createLogger('test-service');
expect(logger).toBeInstanceOf(Logger);
});
it('info() does not throw', () => {
const logger = createLogger('test-service');
expect(() => logger.info('test message')).not.toThrow();
expect(() => logger.info('with context', { key: 'value' })).not.toThrow();
});
it('warn() does not throw', () => {
const logger = createLogger('test-service');
expect(() => logger.warn('warning message')).not.toThrow();
expect(() => logger.warn('with context', { count: 42 })).not.toThrow();
});
it('error() does not throw', () => {
const logger = createLogger('test-service');
expect(() => logger.error('error message')).not.toThrow();
expect(() => logger.error('with error', new Error('boom'))).not.toThrow();
expect(() => logger.error('full', new Error('boom'), { extra: true })).not.toThrow();
});
});
describe('maskUserId', () => {
it('masks a normal user ID correctly', () => {
expect(maskUserId('821596605')).toBe('8215****');
});
it('masks a short ID (<=4 chars) as all asterisks', () => {
expect(maskUserId('1234')).toBe('****');
expect(maskUserId('abc')).toBe('****');
});
it('returns "unknown" for undefined', () => {
expect(maskUserId(undefined)).toBe('unknown');
});
it('returns "unknown" for empty string', () => {
expect(maskUserId('')).toBe('unknown');
});
it('handles numeric input', () => {
expect(maskUserId(821596605)).toBe('8215****');
});
});

66
tests/utils/retry.test.ts Normal file
View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { retryWithBackoff, RetryError } from '../../src/utils/retry';
describe('retryWithBackoff', () => {
it('succeeds on first attempt without retrying', async () => {
let callCount = 0;
const result = await retryWithBackoff(async () => {
callCount++;
return 'ok';
}, { maxRetries: 3, initialDelayMs: 1, jitter: false });
expect(result).toBe('ok');
expect(callCount).toBe(1);
});
it('retries and succeeds on 2nd attempt', async () => {
let callCount = 0;
const result = await retryWithBackoff(async () => {
callCount++;
if (callCount < 2) throw new Error('transient');
return 'recovered';
}, { maxRetries: 3, initialDelayMs: 1, jitter: false });
expect(result).toBe('recovered');
expect(callCount).toBe(2);
});
it('throws RetryError after all attempts exhausted', async () => {
let callCount = 0;
await expect(
retryWithBackoff(async () => {
callCount++;
throw new Error('permanent');
}, { maxRetries: 2, initialDelayMs: 1, jitter: false })
).rejects.toThrow(RetryError);
// 1 initial + 2 retries = 3 total
expect(callCount).toBe(3);
});
it('respects maxRetries option', async () => {
let callCount = 0;
await expect(
retryWithBackoff(async () => {
callCount++;
throw new Error('fail');
}, { maxRetries: 1, initialDelayMs: 1, jitter: false })
).rejects.toThrow(RetryError);
// 1 initial + 1 retry = 2 total
expect(callCount).toBe(2);
});
it('RetryError contains attempt count and last error', async () => {
try {
await retryWithBackoff(async () => {
throw new Error('specific failure');
}, { maxRetries: 2, initialDelayMs: 1, jitter: false });
} catch (error) {
expect(error).toBeInstanceOf(RetryError);
const retryErr = error as RetryError;
expect(retryErr.attempts).toBe(3);
expect(retryErr.lastError.message).toBe('specific failure');
}
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { SessionManager, BaseSession } from '../../src/utils/session-manager';
import { createTestUser, getTestDB } from '../setup';
// Use onboarding_sessions table for testing
const manager = new SessionManager<BaseSession>({
tableName: 'onboarding_sessions',
ttlMs: 30 * 60 * 1000, // 30 minutes
maxMessages: 5,
});
describe('SessionManager', () => {
describe('create()', () => {
it('returns session with correct fields', () => {
const session = manager.create('user123', 'greeting');
expect(session.user_id).toBe('user123');
expect(session.status).toBe('greeting');
expect(session.collected_info).toEqual({});
expect(session.messages).toEqual([]);
expect(session.created_at).toBeTypeOf('number');
expect(session.updated_at).toBeTypeOf('number');
expect(session.expires_at).toBeGreaterThan(session.created_at);
});
});
describe('save() and get() round-trip', () => {
it('saves and retrieves session from DB', async () => {
const db = getTestDB();
const session = manager.create('user456', 'gathering');
session.collected_info = { purpose: 'hosting' };
session.messages = [{ role: 'user', content: 'hello' }];
await manager.save(db, session);
const retrieved = await manager.get(db, 'user456');
expect(retrieved).not.toBeNull();
expect(retrieved!.user_id).toBe('user456');
expect(retrieved!.status).toBe('gathering');
expect(retrieved!.collected_info).toEqual({ purpose: 'hosting' });
expect(retrieved!.messages).toEqual([{ role: 'user', content: 'hello' }]);
});
});
describe('delete()', () => {
it('removes session from DB', async () => {
const db = getTestDB();
const session = manager.create('user789', 'greeting');
await manager.save(db, session);
// Verify it exists
const exists = await manager.has(db, 'user789');
expect(exists).toBe(true);
// Delete
await manager.delete(db, 'user789');
// Verify it's gone
const afterDelete = await manager.get(db, 'user789');
expect(afterDelete).toBeNull();
});
});
describe('has()', () => {
it('returns true for existing session', async () => {
const db = getTestDB();
const session = manager.create('user_has_test', 'greeting');
await manager.save(db, session);
expect(await manager.has(db, 'user_has_test')).toBe(true);
});
it('returns false for missing session', async () => {
const db = getTestDB();
expect(await manager.has(db, 'nonexistent_user')).toBe(false);
});
});
describe('addMessage()', () => {
it('adds message to session', () => {
const session = manager.create('user_msg', 'greeting');
manager.addMessage(session, 'user', 'hello');
expect(session.messages).toHaveLength(1);
expect(session.messages[0]).toEqual({ role: 'user', content: 'hello' });
});
it('trims old messages when over limit', () => {
const session = manager.create('user_trim', 'greeting');
// maxMessages is 5, add 7 messages
for (let i = 0; i < 7; i++) {
manager.addMessage(session, 'user', `message ${i}`);
}
expect(session.messages).toHaveLength(5);
// Should keep the last 5 messages (2..6)
expect(session.messages[0].content).toBe('message 2');
expect(session.messages[4].content).toBe('message 6');
});
});
describe('expired sessions', () => {
it('get() returns null for expired session', async () => {
const db = getTestDB();
// Create a session that's already expired
const now = Date.now();
const expiredSession: BaseSession = {
user_id: 'expired_user',
status: 'greeting',
collected_info: {},
messages: [],
created_at: now - 60000,
updated_at: now - 60000,
expires_at: now - 1000, // expired 1 second ago
};
// Insert directly with past expires_at
await db.prepare(
`INSERT INTO onboarding_sessions (user_id, status, collected_info, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).bind(
expiredSession.user_id,
expiredSession.status,
JSON.stringify(expiredSession.collected_info),
JSON.stringify(expiredSession.messages),
expiredSession.created_at,
expiredSession.updated_at,
expiredSession.expires_at
).run();
const result = await manager.get(db, 'expired_user');
expect(result).toBeNull();
});
});
});

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2021"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

26
vitest.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
exclude: ['**/node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.test.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

63
wrangler.toml Normal file
View File

@@ -0,0 +1,63 @@
name = "telegram-ai-support"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[ai]
binding = "AI"
[vars]
ENVIRONMENT = "production"
# AI Gateway 경유
OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai"
# D2 렌더링 서비스 (jp1 Incus)
D2_RENDER_URL = "http://10.253.100.107:8080"
# 외부 API
NAMECHEAP_API_URL = "https://namecheap-api.anvil.it.com"
WHOIS_API_URL = "https://whois-api-kappa-inoutercoms-projects.vercel.app"
CLOUD_ORCHESTRATOR_URL = "https://cloud-orchestrator.kappa-d8e.workers.dev"
[[d1_databases]]
binding = "DB"
database_name = "telegram-ai-support"
database_id = "REPLACE_WITH_ACTUAL_ID"
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "REPLACE_WITH_ACTUAL_ID"
preview_id = "REPLACE_WITH_ACTUAL_ID"
[[kv_namespaces]]
binding = "SESSION_KV"
id = "REPLACE_WITH_ACTUAL_ID"
preview_id = "REPLACE_WITH_ACTUAL_ID"
[[kv_namespaces]]
binding = "CACHE_KV"
id = "REPLACE_WITH_ACTUAL_ID"
preview_id = "REPLACE_WITH_ACTUAL_ID"
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "ai-support-assets"
# Service Binding
[[services]]
binding = "CLOUD_ORCHESTRATOR"
service = "cloud-orchestrator"
# Cron Triggers:
# - 매일 자정(KST): 만료 알림 + 데이터 아카이빙 + 정합성 검증
# - 매 5분: pending 상태 자동 정리
# - 매 시간: 장애 모니터링 체크
[triggers]
crons = ["0 15 * * *", "*/5 * * * *", "0 * * * *"]
# Secrets (wrangler secret put):
# - BOT_TOKEN: Telegram Bot Token
# - WEBHOOK_SECRET: Webhook 검증용 시크릿
# - OPENAI_API_KEY: OpenAI API 키
# - NAMECHEAP_API_KEY: Namecheap API 래퍼 인증 키
# - ADMIN_TELEGRAM_IDS: 관리자 Telegram ID (콤마 구분)
# - DEPOSIT_BANK_NAME: 입금 은행명
# - DEPOSIT_BANK_ACCOUNT: 입금 계좌번호
# - DEPOSIT_BANK_HOLDER: 예금주