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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
93
CLAUDE.md
Normal file
93
CLAUDE.md
Normal 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
4352
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
337
schema.sql
Normal 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);
|
||||
83
src/agents/agent-registry.ts
Normal file
83
src/agents/agent-registry.ts
Normal 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
408
src/agents/asset-agent.ts
Normal 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
306
src/agents/base-agent.ts
Normal 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
292
src/agents/billing-agent.ts
Normal 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: '거래 취소 중 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
208
src/agents/onboarding-agent.ts
Normal file
208
src/agents/onboarding-agent.ts
Normal 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: '지식 베이스 검색에 실패했습니다.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
295
src/agents/troubleshoot-agent.ts
Normal file
295
src/agents/troubleshoot-agent.ts
Normal 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: '서비스 상태 조회에 실패했습니다.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/constants/agent-config.ts
Normal file
56
src/constants/agent-config.ts
Normal 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
20
src/i18n/en.ts
Normal 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
33
src/i18n/index.ts
Normal 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
20
src/i18n/ko.ts
Normal 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
155
src/index.ts
Normal 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
174
src/routes/api.ts
Normal 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 };
|
||||
242
src/routes/handlers/callback-handler.ts
Normal file
242
src/routes/handlers/callback-handler.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
371
src/routes/handlers/message-handler.ts
Normal file
371
src/routes/handlers/message-handler.ts
Normal 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
13
src/routes/health.ts
Normal 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
96
src/routes/webhook.ts
Normal 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
161
src/security.ts
Normal 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
48
src/services/audit.ts
Normal 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
300
src/services/cron-jobs.ts
Normal 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
73
src/services/feedback.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
161
src/services/human-handoff.ts
Normal file
161
src/services/human-handoff.ts
Normal 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
117
src/services/kv-cache.ts
Normal 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;
|
||||
}
|
||||
60
src/services/notification.ts
Normal file
60
src/services/notification.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
135
src/services/pending-actions.ts
Normal file
135
src/services/pending-actions.ts
Normal 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
235
src/telegram.ts
Normal 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
371
src/tools/admin-tool.ts
Normal 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
109
src/tools/d2-tool.ts
Normal 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
252
src/tools/domain-tool.ts
Normal 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
210
src/tools/index.ts
Normal 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';
|
||||
84
src/tools/knowledge-tool.ts
Normal file
84
src/tools/knowledge-tool.ts
Normal 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
176
src/tools/server-tool.ts
Normal 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
174
src/tools/service-tool.ts
Normal 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
238
src/tools/wallet-tool.ts
Normal 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
470
src/types.ts
Normal 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
26
src/utils/api-urls.ts
Normal 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;
|
||||
}
|
||||
208
src/utils/circuit-breaker.ts
Normal file
208
src/utils/circuit-breaker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/utils/env-validation.ts
Normal file
97
src/utils/env-validation.ts
Normal 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
236
src/utils/logger.ts
Normal 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
109
src/utils/metrics.ts
Normal 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();
|
||||
85
src/utils/optimistic-lock.ts
Normal file
85
src/utils/optimistic-lock.ts
Normal 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
54
src/utils/patterns.ts
Normal 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
144
src/utils/retry.ts
Normal 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!
|
||||
);
|
||||
}
|
||||
231
src/utils/session-manager.ts
Normal file
231
src/utils/session-manager.ts
Normal 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
60
tests/security.test.ts
Normal 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
120
tests/setup.ts
Normal 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
43
tests/tools/index.test.ts
Normal 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('알 수 없는 도구');
|
||||
});
|
||||
});
|
||||
117
tests/utils/circuit-breaker.test.ts
Normal file
117
tests/utils/circuit-breaker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
tests/utils/logger.test.ts
Normal file
51
tests/utils/logger.test.ts
Normal 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
66
tests/utils/retry.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
137
tests/utils/session-manager.test.ts
Normal file
137
tests/utils/session-manager.test.ts
Normal 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
22
tsconfig.json
Normal 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
26
vitest.config.ts
Normal 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
63
wrangler.toml
Normal 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: 예금주
|
||||
Reference in New Issue
Block a user