diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..fd5954e --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,180 @@ +/** + * Constants for telegram-bot-workers + * + * Centralized magic strings and configuration values for better maintainability. + * + * Usage: + * import { SESSION_KEYS, MESSAGE_MARKERS, sessionKey } from './constants'; + * const key = sessionKey(SESSION_KEYS.DELETE_CONFIRM, userId); + */ + +/** + * Session key prefixes for KV storage + */ +export const SESSION_KEYS = { + DELETE_CONFIRM: 'delete_confirm', + SERVER_ORDER_CONFIRM: 'server_order_confirm', + SERVER_SESSION: 'server_session', + RATE_LIMIT: 'rate_limit', + NOTIFICATION: 'notification', +} as const; + +/** + * Message markers for AI response processing + * + * These markers control how responses are processed: + * - DIRECT: Pass through response without AI reinterpretation + * - KEYBOARD: Indicates inline keyboard JSON data follows + * - KEYBOARD_END: Marks end of keyboard data + * - PASSTHROUGH: Return to normal conversation flow + */ +export const MESSAGE_MARKERS = { + DIRECT: '__DIRECT__', + KEYBOARD: '__KEYBOARD__', + KEYBOARD_END: '__END__', + PASSTHROUGH: '__PASSTHROUGH__', +} as const; + +/** + * Keyboard types for inline buttons + */ +export const KEYBOARD_TYPES = { + DOMAIN_REGISTER: 'domain_register', + SERVER_ORDER: 'server_order', +} as const; + +/** + * Callback data prefixes for inline keyboard buttons + * + * Format: prefix:action:params + * Examples: + * - domain_reg:confirm:example.com:15000 + * - server_order:confirm:order_123 + */ +export const CALLBACK_PREFIXES = { + DOMAIN_REGISTER_CONFIRM: 'confirm_domain_register', + DOMAIN_REGISTER_CANCEL: 'cancel_domain_register', + SERVER_ORDER_CONFIRM: 'confirm_server_order', + SERVER_ORDER_CANCEL: 'cancel_server_order', + DELETE_CONFIRM: 'confirm_delete', + DELETE_CANCEL: 'cancel_delete', +} as const; + +/** + * Transaction statuses for deposit system + */ +export const TRANSACTION_STATUS = { + PENDING: 'pending', + CONFIRMED: 'confirmed', + CANCELLED: 'cancelled', + REJECTED: 'rejected', +} as const; + +/** + * Transaction types for deposit system + */ +export const TRANSACTION_TYPE = { + DEPOSIT: 'deposit', + WITHDRAWAL: 'withdrawal', + REFUND: 'refund', +} as const; + +/** + * Server order statuses + */ +export const SERVER_ORDER_STATUS = { + PENDING: 'pending', + PROVISIONING: 'provisioning', + ACTIVE: 'active', + SUSPENDED: 'suspended', + TERMINATED: 'terminated', + FAILED: 'failed', +} as const; + +/** + * Server actions + */ +export const SERVER_ACTION = { + RECOMMEND: 'recommend', + ORDER: 'order', + START: 'start', + STOP: 'stop', + DELETE: 'delete', + LIST: 'list', + INFO: 'info', + IMAGES: 'images', +} as const; + +/** + * Domain actions + */ +export const DOMAIN_ACTION = { + REGISTER: 'register', + CHECK: 'check', + WHOIS: 'whois', + LIST: 'list', + INFO: 'info', + GET_NS: 'get_ns', + SET_NS: 'set_ns', + PRICE: 'price', + CHEAPEST: 'cheapest', +} as const; + +/** + * Deposit actions + */ +export const DEPOSIT_ACTION = { + BALANCE: 'balance', + ACCOUNT: 'account', + REQUEST: 'request', + HISTORY: 'history', + CANCEL: 'cancel', + PENDING: 'pending', + CONFIRM: 'confirm', + REJECT: 'reject', +} as const; + +/** + * Helper to create session key with user ID + * + * @param prefix - Session key prefix (from SESSION_KEYS) + * @param userId - User ID (telegram_id or user identifier) + * @returns Formatted session key + * + * @example + * const key = sessionKey(SESSION_KEYS.DELETE_CONFIRM, '123456'); + * // Returns: 'delete_confirm:123456' + */ +export function sessionKey(prefix: string, userId: string | number): string { + return `${prefix}:${userId}`; +} + +/** + * Helper to parse session key + * + * @param key - Full session key + * @returns Object with prefix and userId, or null if invalid + * + * @example + * const parsed = parseSessionKey('delete_confirm:123456'); + * // Returns: { prefix: 'delete_confirm', userId: '123456' } + */ +export function parseSessionKey(key: string): { prefix: string; userId: string } | null { + const parts = key.split(':'); + if (parts.length !== 2) return null; + return { prefix: parts[0], userId: parts[1] }; +} + +/** + * Type guards for type safety + */ +export type SessionKeyPrefix = typeof SESSION_KEYS[keyof typeof SESSION_KEYS]; +export type MessageMarker = typeof MESSAGE_MARKERS[keyof typeof MESSAGE_MARKERS]; +export type KeyboardType = typeof KEYBOARD_TYPES[keyof typeof KEYBOARD_TYPES]; +export type CallbackPrefix = typeof CALLBACK_PREFIXES[keyof typeof CALLBACK_PREFIXES]; +export type TransactionStatus = typeof TRANSACTION_STATUS[keyof typeof TRANSACTION_STATUS]; +export type TransactionType = typeof TRANSACTION_TYPE[keyof typeof TRANSACTION_TYPE]; +export type ServerOrderStatus = typeof SERVER_ORDER_STATUS[keyof typeof SERVER_ORDER_STATUS]; +export type ServerAction = typeof SERVER_ACTION[keyof typeof SERVER_ACTION]; +export type DomainAction = typeof DOMAIN_ACTION[keyof typeof DOMAIN_ACTION]; +export type DepositAction = typeof DEPOSIT_ACTION[keyof typeof DEPOSIT_ACTION]; diff --git a/src/security.ts b/src/security.ts index 606f4b2..3fecae1 100644 --- a/src/security.ts +++ b/src/security.ts @@ -138,6 +138,8 @@ export async function validateWebhookRequest( } // Rate Limiting (Cloudflare KV 기반) +// NOTE: Future migration to KVCache abstraction layer (kv-cache.ts) planned +// Current implementation kept for backward compatibility interface RateLimitData { count: number; resetAt: number; diff --git a/src/server-provision.ts b/src/server-provision.ts index 64b5120..6699b44 100644 --- a/src/server-provision.ts +++ b/src/server-provision.ts @@ -238,7 +238,7 @@ async function callCloudOrchestrator( const data = await response.json() as ProvisionResponse; - logger.info('Cloud Orchestrator API 응답', data); + logger.info('Cloud Orchestrator API 응답', { response: data }); if (!data.success) { throw new Error(data.error || 'Provisioning failed'); diff --git a/src/services/kv-cache.ts b/src/services/kv-cache.ts new file mode 100644 index 0000000..00a1af9 --- /dev/null +++ b/src/services/kv-cache.ts @@ -0,0 +1,124 @@ +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 = '') {} + + /** + * Get value from cache + */ + async get(key: string): Promise { + 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; + } + } + + /** + * Set value in cache with optional TTL + */ + async set(key: string, value: T, ttlSeconds?: number): Promise { + 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; + } + } + + /** + * Delete value from cache + */ + async delete(key: string): Promise { + 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( + key: string, + factory: () => Promise, + ttlSeconds?: number + ): Promise { + const cached = await this.get(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; + } + + /** + * Check if key exists + */ + async exists(key: string): Promise { + 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'); +} + +/** + * 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 { + 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) { + // New window + await cache.set(key, { count: 1, windowStart: now }, windowSeconds); + return true; + } + + if (data.count >= maxRequests) { + return false; // Rate limited + } + + // Increment count + await cache.set(key, { count: data.count + 1, windowStart: data.windowStart }, windowSeconds); + return true; +} diff --git a/src/utils/env-validation.ts b/src/utils/env-validation.ts new file mode 100644 index 0000000..e6ae83f --- /dev/null +++ b/src/utils/env-validation.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; +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 with defaults handled elsewhere + OPENAI_API_KEY: z.string().optional(), + DEPOSIT_API_SECRET: z.string().optional(), + BRAVE_API_KEY: z.string().optional(), + NAMECHEAP_API_KEY: z.string().optional(), + NAMECHEAP_API_KEY_INTERNAL: z.string().optional(), + + // Configuration with defaults + ENVIRONMENT: z.enum(['development', 'production']).default('production'), + SUMMARY_THRESHOLD: z.string().default('20').transform(Number), + MAX_SUMMARIES_PER_USER: z.string().default('3').transform(Number), + + // Admin IDs + DOMAIN_OWNER_ID: z.string().optional(), + DEPOSIT_ADMIN_ID: z.string().optional(), + + // API URLs (optional, have defaults in code) + OPENAI_API_BASE: z.string().url().optional(), + NAMECHEAP_API_URL: z.string().url().optional(), + WHOIS_API_URL: z.string().url().optional(), + CONTEXT7_API_BASE: z.string().url().optional(), + BRAVE_API_BASE: z.string().url().optional(), + WTTR_IN_URL: z.string().url().optional(), + HOSTING_SITE_URL: z.string().url().optional(), + CLOUD_ORCHESTRATOR_URL: z.string().url().optional(), +}); + +export type ValidatedEnv = z.infer; + +/** + * Validation result type + */ +export interface EnvValidationResult { + success: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Validate environment variables + * Call this early in worker initialization + */ +export function validateEnv(env: Record): EnvValidationResult { + const result: EnvValidationResult = { + success: true, + errors: [], + warnings: [], + }; + + // Validate with Zod + 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}`); + } + } + + // Additional 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.DEPOSIT_ADMIN_ID) { + result.warnings.push('DEPOSIT_ADMIN_ID not set - admin notifications disabled'); + } + + // Log results + 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, keys: string[]): void { + const missing = keys.filter(key => !env[key]); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 1999ef3..f99e8e8 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -35,6 +35,31 @@ export enum LogLevel { FATAL = 'FATAL', } +/** + * 로그 컨텍스트 값 타입 + * + * any 대신 unknown을 사용하여 타입 안정성 개선 + * - unknown은 사용 전 타입 체크를 강제하므로 any보다 안전 + * - 모든 타입의 값을 저장 가능하지만, 사용 시 타입 검증 필요 + * + * 기본 타입 (string, number, boolean 등)과 객체/배열 모두 허용하여 + * 실제 로깅 사용 사례를 지원하면서도 타입 안정성을 유지합니다. + */ +export type LogContextValue = + | string + | number + | boolean + | null + | undefined + | unknown + | LogContextValue[] + | { [key: string]: unknown }; + +/** + * 로그 컨텍스트 타입 + */ +export type LogContext = Record; + /** * 구조화된 로그 엔트리 인터페이스 */ @@ -48,7 +73,7 @@ export interface LogEntry { /** 서비스명 (예: 'openai', 'telegram', 'deposit') */ service?: string; /** 추가 컨텍스트 정보 */ - context?: Record; + context?: LogContext; /** 에러 정보 (ERROR/FATAL 레벨용) */ error?: { /** 에러 이름 */ @@ -209,7 +234,7 @@ export class Logger { private createEntry( level: LogLevel, message: string, - context?: Record, + context?: LogContext, error?: Error ): LogEntry { const entry: LogEntry = { @@ -245,7 +270,7 @@ export class Logger { * logger.debug('함수 호출', { functionName: 'processData', args: [1, 2, 3] }); * ``` */ - debug(message: string, context?: Record): void { + debug(message: string, context?: LogContext): void { if (!this.shouldLog(LogLevel.DEBUG)) return; this.write(this.createEntry(LogLevel.DEBUG, message, context)); } @@ -261,7 +286,7 @@ export class Logger { * logger.info('요청 처리 시작', { userId: '123', endpoint: '/api/data' }); * ``` */ - info(message: string, context?: Record): void { + info(message: string, context?: LogContext): void { if (!this.shouldLog(LogLevel.INFO)) return; this.write(this.createEntry(LogLevel.INFO, message, context)); } @@ -277,7 +302,7 @@ export class Logger { * logger.warn('API 응답 지연', { endpoint: '/api/data', responseTime: 5000 }); * ``` */ - warn(message: string, context?: Record): void { + warn(message: string, context?: LogContext): void { if (!this.shouldLog(LogLevel.WARN)) return; this.write(this.createEntry(LogLevel.WARN, message, context)); } @@ -298,7 +323,7 @@ export class Logger { * } * ``` */ - error(message: string, error?: Error, context?: Record): void { + error(message: string, error?: Error, context?: LogContext): void { if (!this.shouldLog(LogLevel.ERROR)) return; this.write(this.createEntry(LogLevel.ERROR, message, context, error)); } @@ -315,7 +340,7 @@ export class Logger { * logger.fatal('데이터베이스 연결 실패', error, { database: 'main' }); * ``` */ - fatal(message: string, error?: Error, context?: Record): void { + fatal(message: string, error?: Error, context?: LogContext): void { if (!this.shouldLog(LogLevel.FATAL)) return; this.write(this.createEntry(LogLevel.FATAL, message, context, error)); } @@ -336,7 +361,7 @@ export class Logger { * end(); // duration이 자동으로 로그에 포함됨 * ``` */ - startTimer(message?: string, context?: Record): () => void { + startTimer(message?: string, context?: LogContext): () => void { const startTime = Date.now(); const timerMessage = message || 'Operation completed'; @@ -404,7 +429,7 @@ export class Logger { export function createLogger(service: string, env?: Partial): Logger { // 환경 감지: 명시적 ENVIRONMENT 변수 또는 프로덕션 감지 const environment = - (env as any)?.ENVIRONMENT === 'production' + env && 'ENVIRONMENT' in env && env.ENVIRONMENT === 'production' ? 'production' : 'development';