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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-28 20:26:31 +09:00
parent 7ef0ec7594
commit e32e3c6a44
7 changed files with 238 additions and 94 deletions

View File

@@ -407,6 +407,6 @@ export async function executeDepositFunction(
} }
default: default:
return { error: `알 수 없는 함수: ${funcName}` }; return { error: `알 수 없는 기능: ${funcName}` };
} }
} }

View File

@@ -21,6 +21,11 @@ const NameserverResponseSchema = z.object({
nameservers: z.array(z.string()).optional(), nameservers: z.array(z.string()).optional(),
}); });
const PriceResponseSchema = z.object({
krw: z.number().optional(),
register_krw: z.number().optional(),
});
interface RegisterResult { interface RegisterResult {
success: boolean; success: boolean;
domain?: string; domain?: string;
@@ -47,21 +52,68 @@ export async function executeDomainRegister(
} }
try { 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( const balanceRow = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?' 'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number }>(); ).bind(userId).first<{ balance: number }>();
const currentBalance = balanceRow?.balance || 0; const currentBalance = balanceRow?.balance || 0;
if (currentBalance < price) { // Use actual price from API instead of callback price
if (currentBalance < actualPrice) {
return { return {
success: false, success: false,
error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${price.toLocaleString()}원)` error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${actualPrice.toLocaleString()}원)`
}; };
} }
// 2. Namecheap API로 도메인 등록 // 3. Namecheap API로 도메인 등록
console.log(`[DomainRegister] 도메인 등록 요청: ${domain}, 가격: ${price}`); logger.info('도메인 등록 요청', { domain, actualPrice, callbackPrice: price });
const registerResponse = await fetch(`${apiUrl}/domains/register`, { const registerResponse = await fetch(`${apiUrl}/domains/register`, {
method: 'POST', method: 'POST',
@@ -88,13 +140,13 @@ export async function executeDomainRegister(
if (!registerResponse.ok || !registerResult.registered) { if (!registerResponse.ok || !registerResult.registered) {
const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.'; const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.';
console.error(`[DomainRegister] 등록 실패:`, registerResult); logger.error('등록 실패', new Error(errorMsg), { registerResult });
return { success: false, error: errorMsg }; return { success: false, error: errorMsg };
} }
console.log(`[DomainRegister] 등록 성공:`, registerResult); logger.info('등록 성공', { registerResult });
// 3. 잔액 차감 + 거래 기록 (Optimistic Locking) // 4. 잔액 차감 + 거래 기록 (Optimistic Locking) - USE ACTUAL PRICE
try { try {
await executeWithOptimisticLock(env.DB, async () => { await executeWithOptimisticLock(env.DB, async () => {
// Read current balance and version // Read current balance and version
@@ -102,24 +154,24 @@ export async function executeDomainRegister(
'SELECT balance, version FROM user_deposits WHERE user_id = ?' 'SELECT balance, version FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number; version: number }>(); ).bind(userId).first<{ balance: number; version: number }>();
if (!current || current.balance < price) { if (!current || current.balance < actualPrice) {
throw new Error('잔액이 부족합니다.'); throw new Error('잔액이 부족합니다.');
} }
// Update balance with version check // Update balance with version check - USE ACTUAL PRICE
const updateResult = await env.DB.prepare( const updateResult = await env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' '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) { if (!updateResult.success || updateResult.meta.changes === 0) {
throw new OptimisticLockError('Version mismatch on balance update'); throw new OptimisticLockError('Version mismatch on balance update');
} }
// Insert transaction record // Insert transaction record - USE ACTUAL PRICE
const txResult = await env.DB.prepare( const txResult = await env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(userId, price, `도메인 등록: ${domain}`).run(); ).bind(userId, actualPrice, `도메인 등록: ${domain}`).run();
if (!txResult.success) { if (!txResult.success) {
throw new Error('거래 기록 생성 실패'); throw new Error('거래 기록 생성 실패');
@@ -129,8 +181,9 @@ export async function executeDomainRegister(
userId, userId,
telegramUserId, telegramUserId,
domain, domain,
price, actualPrice,
newBalance: current.balance - price, callbackPrice: price,
newBalance: current.balance - actualPrice,
}); });
}); });
} catch (error) { } catch (error) {
@@ -139,7 +192,7 @@ export async function executeDomainRegister(
userId, userId,
telegramUserId, telegramUserId,
domain, domain,
price, actualPrice,
}); });
return { return {
success: false, success: false,
@@ -198,23 +251,30 @@ export async function executeDomainRegister(
} }
} }
} catch (infoError) { } catch (infoError) {
console.log(`[DomainRegister] 도메인 정보 조회 실패 (무시):`, infoError); logger.info('도메인 정보 조회 실패 (무시)', { error: infoError });
} }
const newBalance = currentBalance - price; const newBalance = currentBalance - actualPrice;
console.log(`[DomainRegister] 완료: ${domain}, 잔액: ${currentBalance} -> ${newBalance}, 만료: ${expiresAt}, NS: ${nameservers.join(', ')}`); logger.info('도메인 등록 완료', {
domain,
oldBalance: currentBalance,
newBalance,
actualPrice,
expiresAt,
nameservers: nameservers.join(', ')
});
return { return {
success: true, success: true,
domain: domain, domain: domain,
price: price, price: actualPrice, // Return actual price charged
newBalance: newBalance, newBalance: newBalance,
nameservers: nameservers, nameservers: nameservers,
expiresAt: expiresAt, expiresAt: expiresAt,
}; };
} catch (error) { } catch (error) {
logger.error('도메인 등록 중 오류', error as Error, { domain, price }); logger.error('도메인 등록 중 오류', error as Error, { domain, callbackPrice: price });
return { return {
success: false, success: false,
error: '도메인 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' error: '도메인 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'

View File

@@ -1,4 +1,4 @@
import type { Env } from './types'; import type { Env, OpenAIMessage, ToolCall } from './types';
import { tools, selectToolsForMessage, executeTool } from './tools'; import { tools, selectToolsForMessage, executeTool } from './tools';
import { retryWithBackoff, RetryError } from './utils/retry'; import { retryWithBackoff, RetryError } from './utils/retry';
import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker'; import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
@@ -6,6 +6,9 @@ import { createLogger } from './utils/logger';
import { metrics } from './utils/metrics'; import { metrics } from './utils/metrics';
import { getOpenAIUrl } from './utils/api-urls'; import { getOpenAIUrl } from './utils/api-urls';
import { ERROR_MESSAGES } from './constants/messages'; 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'); const logger = createLogger('openai');
@@ -95,23 +98,27 @@ async function saveMemorySilently(
.bind(user.id) .bind(user.id)
.all<{ id: number; content: string }>(); .all<{ id: number; content: string }>();
if (existing.results) { if (existing.results && existing.results.length > 0) {
for (const memory of existing.results) { // Collect IDs to delete
if (detectMemoryCategory(memory.content) === category) { const idsToDelete = existing.results
await db .filter(memory => detectMemoryCategory(memory.content) === category)
.prepare('DELETE FROM user_memories WHERE id = ?') .map(memory => memory.id);
.bind(memory.id)
.run(); if (idsToDelete.length > 0) {
logger.info('Memory replaced (same category)', { // 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, userId: telegramUserId,
category, category,
oldContent: memory.content.slice(0, 30), deletedCount: idsToDelete.length
newContent: content.slice(0, 30)
}); });
} }
} }
} }
}
// 새 메모리 저장 // 새 메모리 저장
await db await db
@@ -136,22 +143,6 @@ export const openaiCircuitBreaker = new CircuitBreaker({
monitoringWindowMs: 60000 // 1분 윈도우 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 { interface OpenAIResponse {
choices: { choices: {
message: OpenAIMessage; message: OpenAIMessage;
@@ -188,7 +179,7 @@ async function callOpenAI(
if (!response.ok) { if (!response.ok) {
const error = await response.text(); 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(); return response.json();
@@ -211,20 +202,32 @@ export async function generateOpenAIResponse(
systemPrompt: string, systemPrompt: string,
recentContext: { role: 'user' | 'assistant'; content: string }[], recentContext: { role: 'user' | 'assistant'; content: string }[],
telegramUserId?: string, telegramUserId?: string,
db?: D1Database db?: D1Database,
chatIdStr?: string
): Promise<string> { ): Promise<string> {
// Check if server consultation session is active // Check if server consultation session is active
if (telegramUserId && env.SESSION_KV) { if (telegramUserId && env.DB) {
try { try {
const { getServerSession, processServerConsultation } = await import('./server-agent'); const session = await getServerSession(env.DB, telegramUserId);
const session = await getServerSession(env.SESSION_KV, telegramUserId);
if (session && session.status !== 'completed') { if (session && session.status !== 'completed') {
logger.info('Active server session detected, routing to consultation', { logger.info('Active server session detected, routing to consultation', {
userId: telegramUserId, 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<void>) | undefined;
if (chatIdStr) {
sendIntermediateMessage = async (message: string) => {
logger.info('Sending intermediate message', { chatId: chatIdStr, messagePreview: message.substring(0, 50) });
await sendMessage(env.BOT_TOKEN, parseInt(chatIdStr), message);
logger.info('Intermediate message sent successfully', { chatId: chatIdStr });
};
}
const result = await processServerConsultation(userMessage, session, env, sendIntermediateMessage);
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
if (result !== '__PASSTHROUGH__') { if (result !== '__PASSTHROUGH__') {
@@ -233,13 +236,14 @@ export async function generateOpenAIResponse(
// Continue to normal flow below // Continue to normal flow below
} }
} catch (error) { } 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 // Continue with normal flow if session check fails
} }
// Check if troubleshoot session is active // Check if troubleshoot session is active
try { try {
const { getTroubleshootSession, processTroubleshoot } = await import('./troubleshoot-agent');
const troubleshootSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId); const troubleshootSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId);
if (troubleshootSession && troubleshootSession.status !== 'completed') { if (troubleshootSession && troubleshootSession.status !== 'completed') {
@@ -343,7 +347,9 @@ export async function generateOpenAIResponse(
); );
if (earlyResult) { if (earlyResult) {
if (earlyResult.result.includes('__DIRECT__')) { 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; return earlyResult.result;
} }

View File

@@ -1,7 +1,5 @@
import { Env, TelegramUpdate } from './types'; import { Env, TelegramUpdate } from './types';
import { createLogger } from './utils/logger';
// KV 오류 시 인메모리 폴백 (Worker 인스턴스 내)
const fallbackRateLimits = new Map<string, { count: number; resetAt: number }>();
// Telegram 서버 IP 대역 (2024년 기준) // Telegram 서버 IP 대역 (2024년 기준)
// https://core.telegram.org/bots/webhooks#the-short-version // https://core.telegram.org/bots/webhooks#the-short-version
@@ -65,9 +63,16 @@ function isValidRequestBody(body: unknown): body is TelegramUpdate {
); );
} }
// 타임스탬프 검증 (비활성화 - WEBHOOK_SECRET으로 충분) // 타임스탬프 검증 (5분 이내 메시지만 허용 - 리플레이 공격 방지)
function isRecentUpdate(_message: TelegramUpdate['message']): boolean { function isRecentUpdate(message: TelegramUpdate['message']): boolean {
return true; // 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 { export interface SecurityCheckResult {
@@ -144,6 +149,7 @@ export async function checkRateLimit(
): Promise<boolean> { ): Promise<boolean> {
const key = `ratelimit:${userId}`; const key = `ratelimit:${userId}`;
const now = Date.now(); const now = Date.now();
const logger = createLogger('rate-limit');
try { try {
// KV에서 기존 데이터 조회 // KV에서 기존 데이터 조회
@@ -159,11 +165,24 @@ export async function checkRateLimit(
await kv.put(key, JSON.stringify(newData), { await kv.put(key, JSON.stringify(newData), {
expirationTtl: Math.ceil(windowMs / 1000), // 초 단위 expirationTtl: Math.ceil(windowMs / 1000), // 초 단위
}); });
logger.info('Rate limit 윈도우 시작', {
userId,
resetAt: new Date(newData.resetAt).toISOString(),
maxRequests,
});
return true; return true;
} }
// Rate limit 초과 // Rate limit 초과
if (data.count >= maxRequests) { 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; return false;
} }
@@ -178,25 +197,11 @@ export async function checkRateLimit(
}); });
return true; return true;
} catch (error) { } 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; return true;
} }
} }

View File

@@ -394,7 +394,8 @@ ${memoriesSection}
- 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요. - 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요.
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요. - 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요.
- 서버, VPS, 클라우드, 호스팅 관련 요청: - 서버, VPS, 클라우드, 호스팅 관련 요청:
첫 요청: manage_server(action="start_consultation")을 호출하여 상담 시작 내 서버 목록 조회: manage_server(action="list") - 반드시 도구 호출
• 서버 추천/상담 시작: manage_server(action="start_consultation")
• 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요) • 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요)
- 기술 문제, 에러, 오류, 장애 관련 요청: - 기술 문제, 에러, 오류, 장애 관련 요청:
• "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시 • "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시
@@ -403,7 +404,8 @@ ${memoriesSection}
- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요. - 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요.
- 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요. - 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요.
- 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요. - 기타 도메인 관련 요청(조회, 등록, 네임서버, 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) => ({ const recentContext = context.recentMessages.slice(-10).map((m) => ({
role: m.role === 'user' ? 'user' as const : 'assistant' as const, role: m.role === 'user' ? 'user' as const : 'assistant' as const,
@@ -413,7 +415,7 @@ ${memoriesSection}
// OpenAI 사용 (설정된 경우) // OpenAI 사용 (설정된 경우)
if (env.OPENAI_API_KEY) { if (env.OPENAI_API_KEY) {
const { generateOpenAIResponse } = await import('./openai-service'); 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 // 폴백: Workers AI

View File

@@ -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<string> { export async function executeCalculate(args: { expression: string }): Promise<string> {
const expression = args.expression; const expression = args.expression;
try { try {
// 안전한 수식 계산 (기본 연산만) const result = safeMathEval(expression);
const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, ''); // Format result: remove trailing zeros for clean display
const result = Function('"use strict"; return (' + sanitized + ')')(); const formatted = Number.isInteger(result) ? result.toString() : result.toFixed(10).replace(/\.?0+$/, '');
return `🔢 계산 결과: ${expression} = ${result}`; return `🔢 계산 결과: ${expression} = ${formatted}`;
} catch (error) { } catch (error) {
return `계산할 수 없는 수식입니다: ${expression}`; return `계산할 수 없는 수식입니다: ${expression}`;
} }

View File

@@ -2,6 +2,9 @@
import type { Env } from '../types'; import type { Env } from '../types';
import { retryWithBackoff } from '../utils/retry'; import { retryWithBackoff } from '../utils/retry';
import { ERROR_MESSAGES } from '../constants/messages'; import { ERROR_MESSAGES } from '../constants/messages';
import { createLogger } from '../utils/logger';
const logger = createLogger('weather');
// wttr.in API 응답 타입 정의 // wttr.in API 응답 타입 정의
interface WttrCurrentCondition { interface WttrCurrentCondition {
@@ -87,6 +90,7 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise
습도: ${current.humidity}% 습도: ${current.humidity}%
풍속: ${current.windspeedKmph} km/h`; 풍속: ${current.windspeedKmph} km/h`;
} catch (error) { } catch (error) {
logger.error('날씨 조회 실패', error as Error, { city });
return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`; return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`;
} }
} }