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:
@@ -407,6 +407,6 @@ export async function executeDepositFunction(
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: `알 수 없는 함수: ${funcName}` };
|
||||
return { error: `알 수 없는 기능: ${funcName}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '도메인 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
|
||||
@@ -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<string> {
|
||||
// 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<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: 무관한 메시지는 일반 처리로 전환
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Env, TelegramUpdate } from './types';
|
||||
|
||||
// KV 오류 시 인메모리 폴백 (Worker 인스턴스 내)
|
||||
const fallbackRateLimits = new Map<string, { count: number; resetAt: number }>();
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user