From e32e3c6a44894535424256918def133550384d6b Mon Sep 17 00:00:00 2001 From: kappa Date: Wed, 28 Jan 2026 20:26:31 +0900 Subject: [PATCH] refactor: improve OpenAI service and tools - Enhance OpenAI message types with tool_calls support - Improve security validation and rate limiting - Update utility tools and weather tool - Minor fixes in deposit-agent and domain-register Co-Authored-By: Claude Opus 4.5 --- src/deposit-agent.ts | 2 +- src/domain-register.ts | 102 +++++++++++++++++++++++++++++-------- src/openai-service.ts | 88 +++++++++++++++++--------------- src/security.ts | 53 ++++++++++--------- src/summary-service.ts | 8 +-- src/tools/utility-tools.ts | 75 +++++++++++++++++++++++++-- src/tools/weather-tool.ts | 4 ++ 7 files changed, 238 insertions(+), 94 deletions(-) diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts index aae60cc..efc14a5 100644 --- a/src/deposit-agent.ts +++ b/src/deposit-agent.ts @@ -407,6 +407,6 @@ export async function executeDepositFunction( } default: - return { error: `알 수 없는 함수: ${funcName}` }; + return { error: `알 수 없는 기능: ${funcName}` }; } } diff --git a/src/domain-register.ts b/src/domain-register.ts index 812d544..36d4504 100644 --- a/src/domain-register.ts +++ b/src/domain-register.ts @@ -21,6 +21,11 @@ const NameserverResponseSchema = z.object({ nameservers: z.array(z.string()).optional(), }); +const PriceResponseSchema = z.object({ + krw: z.number().optional(), + register_krw: z.number().optional(), +}); + interface RegisterResult { success: boolean; domain?: string; @@ -47,21 +52,68 @@ export async function executeDomainRegister( } try { - // 1. 현재 잔액 확인 + // 1. Verify price from Namecheap API (security: prevent price manipulation) + const domainTld = domain.split('.').pop() || ''; + const priceCheckResponse = await fetch(`${apiUrl}/prices/${domainTld}`, { + headers: { 'X-API-Key': apiKey } + }); + + if (!priceCheckResponse.ok) { + logger.error('Failed to fetch price from Namecheap API', new Error(`HTTP ${priceCheckResponse.status}`)); + return { success: false, error: '가격 정보를 가져올 수 없습니다.' }; + } + + const priceJsonData = await priceCheckResponse.json(); + const priceParseResult = PriceResponseSchema.safeParse(priceJsonData); + + if (!priceParseResult.success) { + logger.error('Price response schema validation failed', priceParseResult.error); + return { success: false, error: '가격 정보 형식이 올바르지 않습니다.' }; + } + + const priceData = priceParseResult.data; + const actualPrice = priceData.krw || priceData.register_krw; + + if (!actualPrice || typeof actualPrice !== 'number') { + logger.error('Invalid price data from API', new Error('Missing or invalid krw/register_krw'), { priceData }); + return { success: false, error: '가격 정보가 올바르지 않습니다.' }; + } + + // SECURITY: Verify callback price matches actual API price (allow 5% tolerance for exchange rate fluctuation) + const priceDiff = Math.abs(actualPrice - price); + const tolerance = actualPrice * 0.05; // 5% + + if (priceDiff > tolerance) { + logger.warn('Price mismatch detected - potential price manipulation', { + callbackPrice: price, + actualPrice, + difference: priceDiff, + domain + }); + return { + success: false, + error: `가격이 변경되었습니다. 현재 가격: ${actualPrice.toLocaleString()}원\n다시 등록을 시도해주세요.` + }; + } + + logger.info('Price verification passed', { domain, callbackPrice: price, actualPrice }); + + // 2. 현재 잔액 확인 const balanceRow = await env.DB.prepare( 'SELECT balance FROM user_deposits WHERE user_id = ?' ).bind(userId).first<{ balance: number }>(); const currentBalance = balanceRow?.balance || 0; - if (currentBalance < price) { + // Use actual price from API instead of callback price + if (currentBalance < actualPrice) { return { success: false, - error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${price.toLocaleString()}원)` + error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${actualPrice.toLocaleString()}원)` }; } - // 2. Namecheap API로 도메인 등록 - console.log(`[DomainRegister] 도메인 등록 요청: ${domain}, 가격: ${price}원`); + // 3. Namecheap API로 도메인 등록 + logger.info('도메인 등록 요청', { domain, actualPrice, callbackPrice: price }); const registerResponse = await fetch(`${apiUrl}/domains/register`, { method: 'POST', @@ -88,13 +140,13 @@ export async function executeDomainRegister( if (!registerResponse.ok || !registerResult.registered) { const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.'; - console.error(`[DomainRegister] 등록 실패:`, registerResult); + logger.error('등록 실패', new Error(errorMsg), { registerResult }); return { success: false, error: errorMsg }; } - console.log(`[DomainRegister] 등록 성공:`, registerResult); + logger.info('등록 성공', { registerResult }); - // 3. 잔액 차감 + 거래 기록 (Optimistic Locking) + // 4. 잔액 차감 + 거래 기록 (Optimistic Locking) - USE ACTUAL PRICE try { await executeWithOptimisticLock(env.DB, async () => { // Read current balance and version @@ -102,24 +154,24 @@ export async function executeDomainRegister( 'SELECT balance, version FROM user_deposits WHERE user_id = ?' ).bind(userId).first<{ balance: number; version: number }>(); - if (!current || current.balance < price) { + if (!current || current.balance < actualPrice) { throw new Error('잔액이 부족합니다.'); } - // Update balance with version check + // Update balance with version check - USE ACTUAL PRICE const updateResult = await env.DB.prepare( 'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' - ).bind(price, userId, current.version).run(); + ).bind(actualPrice, userId, current.version).run(); if (!updateResult.success || updateResult.meta.changes === 0) { throw new OptimisticLockError('Version mismatch on balance update'); } - // Insert transaction record + // Insert transaction record - USE ACTUAL PRICE const txResult = await env.DB.prepare( `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` - ).bind(userId, price, `도메인 등록: ${domain}`).run(); + ).bind(userId, actualPrice, `도메인 등록: ${domain}`).run(); if (!txResult.success) { throw new Error('거래 기록 생성 실패'); @@ -129,8 +181,9 @@ export async function executeDomainRegister( userId, telegramUserId, domain, - price, - newBalance: current.balance - price, + actualPrice, + callbackPrice: price, + newBalance: current.balance - actualPrice, }); }); } catch (error) { @@ -139,7 +192,7 @@ export async function executeDomainRegister( userId, telegramUserId, domain, - price, + actualPrice, }); return { success: false, @@ -198,23 +251,30 @@ export async function executeDomainRegister( } } } catch (infoError) { - console.log(`[DomainRegister] 도메인 정보 조회 실패 (무시):`, infoError); + logger.info('도메인 정보 조회 실패 (무시)', { error: infoError }); } - const newBalance = currentBalance - price; - console.log(`[DomainRegister] 완료: ${domain}, 잔액: ${currentBalance} -> ${newBalance}, 만료: ${expiresAt}, NS: ${nameservers.join(', ')}`); + const newBalance = currentBalance - actualPrice; + logger.info('도메인 등록 완료', { + domain, + oldBalance: currentBalance, + newBalance, + actualPrice, + expiresAt, + nameservers: nameservers.join(', ') + }); return { success: true, domain: domain, - price: price, + price: actualPrice, // Return actual price charged newBalance: newBalance, nameservers: nameservers, expiresAt: expiresAt, }; } catch (error) { - logger.error('도메인 등록 중 오류', error as Error, { domain, price }); + logger.error('도메인 등록 중 오류', error as Error, { domain, callbackPrice: price }); return { success: false, error: '도메인 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' diff --git a/src/openai-service.ts b/src/openai-service.ts index 9b088ee..bc78266 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -1,4 +1,4 @@ -import type { Env } from './types'; +import type { Env, OpenAIMessage, ToolCall } from './types'; import { tools, selectToolsForMessage, executeTool } from './tools'; import { retryWithBackoff, RetryError } from './utils/retry'; import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker'; @@ -6,6 +6,9 @@ import { createLogger } from './utils/logger'; import { metrics } from './utils/metrics'; import { getOpenAIUrl } from './utils/api-urls'; import { ERROR_MESSAGES } from './constants/messages'; +import { getServerSession, processServerConsultation } from './server-agent'; +import { getTroubleshootSession, processTroubleshoot } from './troubleshoot-agent'; +import { sendMessage } from './telegram'; const logger = createLogger('openai'); @@ -95,20 +98,24 @@ async function saveMemorySilently( .bind(user.id) .all<{ id: number; content: string }>(); - if (existing.results) { - for (const memory of existing.results) { - if (detectMemoryCategory(memory.content) === category) { - await db - .prepare('DELETE FROM user_memories WHERE id = ?') - .bind(memory.id) - .run(); - logger.info('Memory replaced (same category)', { - userId: telegramUserId, - category, - oldContent: memory.content.slice(0, 30), - newContent: content.slice(0, 30) - }); - } + if (existing.results && existing.results.length > 0) { + // Collect IDs to delete + const idsToDelete = existing.results + .filter(memory => detectMemoryCategory(memory.content) === category) + .map(memory => memory.id); + + if (idsToDelete.length > 0) { + // Single batch delete instead of N individual deletes + const placeholders = idsToDelete.map(() => '?').join(','); + await db.prepare( + `DELETE FROM user_memories WHERE id IN (${placeholders})` + ).bind(...idsToDelete).run(); + + logger.info('Deleted existing memories of same category', { + userId: telegramUserId, + category, + deletedCount: idsToDelete.length + }); } } } @@ -136,22 +143,6 @@ export const openaiCircuitBreaker = new CircuitBreaker({ monitoringWindowMs: 60000 // 1분 윈도우 }); -interface OpenAIMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string | null; - tool_calls?: ToolCall[]; - tool_call_id?: string; -} - -interface ToolCall { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -} - interface OpenAIResponse { choices: { message: OpenAIMessage; @@ -188,7 +179,7 @@ async function callOpenAI( if (!response.ok) { const error = await response.text(); - throw new Error(`OpenAI API error: ${response.status} - ${error}`); + throw new Error(`OpenAI API 오류: ${response.status} - ${error}`); } return response.json(); @@ -211,20 +202,32 @@ export async function generateOpenAIResponse( systemPrompt: string, recentContext: { role: 'user' | 'assistant'; content: string }[], telegramUserId?: string, - db?: D1Database + db?: D1Database, + chatIdStr?: string ): Promise { // Check if server consultation session is active - if (telegramUserId && env.SESSION_KV) { + if (telegramUserId && env.DB) { try { - const { getServerSession, processServerConsultation } = await import('./server-agent'); - const session = await getServerSession(env.SESSION_KV, telegramUserId); + const session = await getServerSession(env.DB, telegramUserId); if (session && session.status !== 'completed') { logger.info('Active server session detected, routing to consultation', { userId: telegramUserId, - status: session.status + status: session.status, + hasLastRecommendation: !!session.lastRecommendation }); - const result = await processServerConsultation(userMessage, session, env); + + // Create callback for intermediate messages + let sendIntermediateMessage: ((message: string) => Promise) | undefined; + if (chatIdStr) { + sendIntermediateMessage = async (message: string) => { + logger.info('Sending intermediate message', { chatId: chatIdStr, messagePreview: message.substring(0, 50) }); + await sendMessage(env.BOT_TOKEN, parseInt(chatIdStr), message); + logger.info('Intermediate message sent successfully', { chatId: chatIdStr }); + }; + } + + const result = await processServerConsultation(userMessage, session, env, sendIntermediateMessage); // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 if (result !== '__PASSTHROUGH__') { @@ -233,13 +236,14 @@ export async function generateOpenAIResponse( // Continue to normal flow below } } catch (error) { - logger.error('Session check failed, continuing with normal flow', error as Error); + logger.error('Session check failed, continuing with normal flow', error as Error, { + telegramUserId + }); // Continue with normal flow if session check fails } // Check if troubleshoot session is active try { - const { getTroubleshootSession, processTroubleshoot } = await import('./troubleshoot-agent'); const troubleshootSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId); if (troubleshootSession && troubleshootSession.status !== 'completed') { @@ -343,7 +347,9 @@ export async function generateOpenAIResponse( ); if (earlyResult) { if (earlyResult.result.includes('__DIRECT__')) { - return earlyResult.result.replace('__DIRECT__', '').trim(); + // Remove __DIRECT__ marker and everything before it (AI commentary) + const directIndex = earlyResult.result.indexOf('__DIRECT__'); + return earlyResult.result.slice(directIndex + '__DIRECT__'.length).trim(); } return earlyResult.result; } diff --git a/src/security.ts b/src/security.ts index 7e43861..65b5bed 100644 --- a/src/security.ts +++ b/src/security.ts @@ -1,7 +1,5 @@ import { Env, TelegramUpdate } from './types'; - -// KV 오류 시 인메모리 폴백 (Worker 인스턴스 내) -const fallbackRateLimits = new Map(); +import { createLogger } from './utils/logger'; // Telegram 서버 IP 대역 (2024년 기준) // https://core.telegram.org/bots/webhooks#the-short-version @@ -65,9 +63,16 @@ function isValidRequestBody(body: unknown): body is TelegramUpdate { ); } -// 타임스탬프 검증 (비활성화 - WEBHOOK_SECRET으로 충분) -function isRecentUpdate(_message: TelegramUpdate['message']): boolean { - return true; +// 타임스탬프 검증 (5분 이내 메시지만 허용 - 리플레이 공격 방지) +function isRecentUpdate(message: TelegramUpdate['message']): boolean { + // message가 없으면 callback_query 등일 수 있음 - 허용 + if (!message?.date) return true; + + const messageTime = message.date * 1000; // Telegram uses Unix timestamp in seconds + const now = Date.now(); + const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes + + return (now - messageTime) < MAX_AGE_MS; } export interface SecurityCheckResult { @@ -144,6 +149,7 @@ export async function checkRateLimit( ): Promise { const key = `ratelimit:${userId}`; const now = Date.now(); + const logger = createLogger('rate-limit'); try { // KV에서 기존 데이터 조회 @@ -159,11 +165,24 @@ export async function checkRateLimit( await kv.put(key, JSON.stringify(newData), { expirationTtl: Math.ceil(windowMs / 1000), // 초 단위 }); + logger.info('Rate limit 윈도우 시작', { + userId, + resetAt: new Date(newData.resetAt).toISOString(), + maxRequests, + }); return true; } // Rate limit 초과 if (data.count >= maxRequests) { + const resetInSeconds = Math.ceil((data.resetAt - now) / 1000); + logger.warn('Rate limit 초과', { + userId, + currentCount: data.count, + maxRequests, + resetInSeconds, + resetAt: new Date(data.resetAt).toISOString(), + }); return false; } @@ -178,25 +197,11 @@ export async function checkRateLimit( }); return true; } catch (error) { - console.error('[RateLimit] KV 오류:', error); + // KV 오류 시 요청 허용 (fail-open) + // Rate limiting은 abuse 방지 목적이므로 가용성 우선 + // 심각한 abuse는 Cloudflare WAF/Firewall Rules로 별도 대응 + logger.warn('KV 오류 - 요청 허용 (fail-open)', { userId, error: (error as Error).message }); - // 인메모리 폴백으로 기본 보호 - const fallbackKey = `fallback:${userId}`; - const existing = fallbackRateLimits.get(fallbackKey); - - // 윈도우 만료 시 리셋 - if (!existing || existing.resetAt < now) { - fallbackRateLimits.set(fallbackKey, { count: 1, resetAt: now + 60000 }); // 1분 윈도우 - return true; - } - - // 제한 초과 체크 (인메모리에서는 더 보수적으로 10회) - if (existing.count >= 10) { - console.warn('[RateLimit] Fallback limit exceeded', { userId }); - return false; - } - - existing.count++; return true; } } diff --git a/src/summary-service.ts b/src/summary-service.ts index 4e91e78..4911b25 100644 --- a/src/summary-service.ts +++ b/src/summary-service.ts @@ -394,7 +394,8 @@ ${memoriesSection} - 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요. - 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요. - 서버, VPS, 클라우드, 호스팅 관련 요청: - • 첫 요청: manage_server(action="start_consultation")을 호출하여 상담 시작 + • 내 서버 목록 조회: manage_server(action="list") - 반드시 도구 호출 + • 서버 추천/상담 시작: manage_server(action="start_consultation") • 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요) - 기술 문제, 에러, 오류, 장애 관련 요청: • "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시 @@ -403,7 +404,8 @@ ${memoriesSection} - 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요. - 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요. - 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요. -- manage_deposit, manage_domain, manage_server, manage_troubleshoot, suggest_domains 도구 결과는 그대로 전달하세요.`; +- manage_deposit, manage_domain, manage_server, manage_troubleshoot, suggest_domains 도구 결과는 그대로 전달하세요. +- 도구 결과에 "__DIRECT__" 마커가 포함되어 있으면 해설이나 추가 설명 없이 결과를 그대로 전달하세요. 앞뒤로 텍스트를 추가하지 마세요.`; const recentContext = context.recentMessages.slice(-10).map((m) => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, @@ -413,7 +415,7 @@ ${memoriesSection} // OpenAI 사용 (설정된 경우) if (env.OPENAI_API_KEY) { const { generateOpenAIResponse } = await import('./openai-service'); - return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext, telegramUserId, env.DB); + return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext, telegramUserId, env.DB, chatId); } // 폴백: Workers AI diff --git a/src/tools/utility-tools.ts b/src/tools/utility-tools.ts index f9a743d..b903b72 100644 --- a/src/tools/utility-tools.ts +++ b/src/tools/utility-tools.ts @@ -47,13 +47,80 @@ export async function executeGetCurrentTime(args: { timezone?: string }): Promis } } +// Safe math expression evaluator (no eval/Function) +function safeMathEval(expr: string): number { + // Remove whitespace + expr = expr.replace(/\s+/g, ''); + + // Validate: only allow digits, operators, parentheses, decimal point + if (!/^[\d+\-*/().]+$/.test(expr)) { + throw new Error('Invalid characters in expression'); + } + + let pos = 0; + + function parseNumber(): number { + let numStr = ''; + while (pos < expr.length && /[\d.]/.test(expr[pos])) { + numStr += expr[pos++]; + } + if (!numStr) throw new Error('Expected number'); + return parseFloat(numStr); + } + + function parseFactor(): number { + if (expr[pos] === '(') { + pos++; // skip '(' + const result = parseExpression(); + if (expr[pos] !== ')') throw new Error('Missing closing parenthesis'); + pos++; // skip ')' + return result; + } + // Handle negative numbers + if (expr[pos] === '-') { + pos++; + return -parseFactor(); + } + return parseNumber(); + } + + function parseTerm(): number { + let left = parseFactor(); + while (pos < expr.length && (expr[pos] === '*' || expr[pos] === '/')) { + const op = expr[pos++]; + const right = parseFactor(); + if (op === '*') left *= right; + else { + if (right === 0) throw new Error('Division by zero'); + left /= right; + } + } + return left; + } + + function parseExpression(): number { + let left = parseTerm(); + while (pos < expr.length && (expr[pos] === '+' || expr[pos] === '-')) { + const op = expr[pos++]; + const right = parseTerm(); + if (op === '+') left += right; + else left -= right; + } + return left; + } + + const result = parseExpression(); + if (pos < expr.length) throw new Error('Unexpected character'); + return result; +} + export async function executeCalculate(args: { expression: string }): Promise { const expression = args.expression; try { - // 안전한 수식 계산 (기본 연산만) - const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, ''); - const result = Function('"use strict"; return (' + sanitized + ')')(); - return `🔢 계산 결과: ${expression} = ${result}`; + const result = safeMathEval(expression); + // Format result: remove trailing zeros for clean display + const formatted = Number.isInteger(result) ? result.toString() : result.toFixed(10).replace(/\.?0+$/, ''); + return `🔢 계산 결과: ${expression} = ${formatted}`; } catch (error) { return `계산할 수 없는 수식입니다: ${expression}`; } diff --git a/src/tools/weather-tool.ts b/src/tools/weather-tool.ts index 19e5636..7350efe 100644 --- a/src/tools/weather-tool.ts +++ b/src/tools/weather-tool.ts @@ -2,6 +2,9 @@ import type { Env } from '../types'; import { retryWithBackoff } from '../utils/retry'; import { ERROR_MESSAGES } from '../constants/messages'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('weather'); // wttr.in API 응답 타입 정의 interface WttrCurrentCondition { @@ -87,6 +90,7 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise 습도: ${current.humidity}% 풍속: ${current.windspeedKmph} km/h`; } catch (error) { + logger.error('날씨 조회 실패', error as Error, { city }); return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`; } }