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:
|
default:
|
||||||
return { error: `알 수 없는 함수: ${funcName}` };
|
return { error: `알 수 없는 기능: ${funcName}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '도메인 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user