diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts index f7ff288..2b06300 100644 --- a/src/deposit-agent.ts +++ b/src/deposit-agent.ts @@ -23,9 +23,20 @@ export interface DepositContext { telegramUserId: string; isAdmin: boolean; db: D1Database; + env?: { + DEPOSIT_BANK_NAME?: string; + DEPOSIT_BANK_ACCOUNT?: string; + DEPOSIT_BANK_HOLDER?: string; + }; } -// 예치금 API 함수 실행 (export for direct use without Agent) +/** + * Execute deposit-related functions + * @param funcName - Function name (get_balance, request_deposit, etc.) + * @param funcArgs - Function arguments validated by Zod schema + * @param context - User context including userId, isAdmin, db, env + * @returns Promise + */ export async function executeDepositFunction( funcName: string, funcArgs: ManageDepositArgs, @@ -55,9 +66,9 @@ export async function executeDepositFunction( case 'get_account_info': { return { - bank: '하나은행', - account: '427-910018-27104', - holder: '주식회사 아이언클래드', + bank: context.env?.DEPOSIT_BANK_NAME || '하나은행', + account: context.env?.DEPOSIT_BANK_ACCOUNT || '427-910018-27104', + holder: context.env?.DEPOSIT_BANK_HOLDER || '주식회사 아이언클래드', instruction: '입금 후 입금자명과 금액을 알려주세요.', }; } @@ -164,9 +175,9 @@ export async function executeDepositFunction( status: 'pending', message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.', account_info: { - bank: '하나은행', - account: '427-910018-27104', - holder: '주식회사 아이언클래드', + bank: context.env?.DEPOSIT_BANK_NAME || '하나은행', + account: context.env?.DEPOSIT_BANK_ACCOUNT || '427-910018-27104', + holder: context.env?.DEPOSIT_BANK_HOLDER || '주식회사 아이언클래드', }, }; } diff --git a/src/openai-service.ts b/src/openai-service.ts index 0c5ac1d..dbd6afd 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -130,7 +130,16 @@ export async function generateOpenAIResponse( // 도구 호출 결과 수집 const toolResults: OpenAIMessage[] = []; for (const toolCall of assistantMessage.tool_calls) { - const args = JSON.parse(toolCall.function.arguments); + let args: Record; + try { + args = JSON.parse(toolCall.function.arguments); + } catch (parseError) { + logger.error('Failed to parse tool arguments', parseError as Error, { + toolName: toolCall.function.name, + raw: toolCall.function.arguments.slice(0, 200) // 일부만 로깅 + }); + continue; // 다음 tool call로 진행 + } const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db); // __KEYBOARD__ 마커가 있으면 AI 재해석 없이 바로 반환 (버튼 보존) diff --git a/src/tools/domain-tool.ts b/src/tools/domain-tool.ts index 0d88410..d135b95 100644 --- a/src/tools/domain-tool.ts +++ b/src/tools/domain-tool.ts @@ -39,6 +39,12 @@ function isNamecheapPriceResponse(result: unknown): result is NamecheapPriceResp return typeof result === 'object' && result !== null && 'krw' in result; } +// Namecheap 날짜 형식 변환 (MM/DD/YYYY → YYYY-MM-DD) +function convertNamecheapDate(date: string): string { + const [month, day, year] = date.split('/'); + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; +} + // KV 캐싱 인터페이스 interface CachedTLDPrice { tld: string; @@ -75,9 +81,10 @@ async function setCachedTLDPrice( ): Promise { try { const key = `tld_price:${tld}`; + const krwPrice = price.krw ?? price.register_krw ?? 0; const data: CachedTLDPrice = { tld, - krw: price.krw || price.register_krw, + krw: krwPrice, usd: price.usd, cached_at: new Date().toISOString(), }; @@ -210,18 +217,13 @@ async function callNamecheapApi( }).then(r => r.json()), { maxRetries: 3 } ) as NamecheapDomainListItem[]; - // MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용) - const convertDate = (date: string) => { - const [month, day, year] = date.split('/'); - return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; - }; // 허용된 도메인만 필터링, 날짜는 ISO 형식으로 변환 return result .filter((d: NamecheapDomainListItem) => allowedDomains.includes(d.name)) .map((d: NamecheapDomainListItem) => ({ ...d, - created: convertDate(d.created), - expires: convertDate(d.expires), + created: convertNamecheapDate(d.created), + expires: convertNamecheapDate(d.expires), user: undefined, // 민감 정보 제거 })); } @@ -238,16 +240,11 @@ async function callNamecheapApi( if (!domainInfo) { return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` }; } - // MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용) - const convertDate = (date: string) => { - const [month, day, year] = date.split('/'); - return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; - }; // 민감 정보 필터링 (user/owner 제거), 날짜는 ISO 형식으로 변환 return { domain: domainInfo.name, - created: convertDate(domainInfo.created), - expires: convertDate(domainInfo.expires), + created: convertNamecheapDate(domainInfo.created), + expires: convertNamecheapDate(domainInfo.expires), is_expired: domainInfo.is_expired, auto_renew: domainInfo.auto_renew, is_locked: domainInfo.is_locked, @@ -275,8 +272,9 @@ async function callNamecheapApi( if (!res.ok) { // Namecheap 에러 메시지 파싱 if (text.includes('subordinate hosts') || text.includes('Non existen')) { + const nsArray = Array.isArray(nameservers) ? nameservers : []; return { - error: `네임서버 변경 실패: ${funcArgs.nameservers.join(', ')}는 등록되지 않은 네임서버입니다. 자기 도메인을 네임서버로 사용하려면 먼저 Namecheap에서 Child Nameserver(글루 레코드)를 IP 주소와 함께 등록해야 합니다.` + error: `네임서버 변경 실패: ${nsArray.join(', ')}는 등록되지 않은 네임서버입니다. 자기 도메인을 네임서버로 사용하려면 먼저 Namecheap에서 Child Nameserver(글루 레코드)를 IP 주소와 함께 등록해야 합니다.` }; } return { error: `네임서버 변경 실패: ${text}` }; @@ -485,19 +483,34 @@ async function executeDomainAction( } const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, env, telegramUserId, db, userId); - - if (result.error) { + + if (isErrorResult(result)) { // 계정 내 도메인 정보 조회 실패 시 WHOIS로 폴백 return executeDomainAction('whois', args, allowedDomains, env, telegramUserId, db, userId); } - return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`; + + // Type assertion after error check + const domainInfo = result as { + domain: string; + created: string; + expires: string; + is_expired: boolean; + auto_renew: boolean; + is_locked: boolean; + whois_guard: boolean; + }; + + return `📋 ${domain} 정보\n\n• 생성일: ${domainInfo.created}\n• 만료일: ${domainInfo.expires}\n• 자동갱신: ${domainInfo.auto_renew ? '✅' : '❌'}\n• 잠금: ${domainInfo.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${domainInfo.whois_guard ? '✅' : '❌'}`; } case 'get_ns': { if (!domain) return '🚫 도메인을 지정해주세요.'; const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, env, telegramUserId, db, userId); - if (result.error) return `🚫 ${result.error}`; - const nsList = (result.nameservers || result).map((ns: string) => `• ${ns}`).join('\n'); + if (isErrorResult(result)) return `🚫 ${result.error}`; + + const nsResult = result as { nameservers?: string[] } | string[]; + const nameserverList = Array.isArray(nsResult) ? nsResult : (nsResult.nameservers || []); + const nsList = nameserverList.map((ns: string) => `• ${ns}`).join('\n'); return `🌐 ${domain} 네임서버\n\n${nsList}`; } @@ -506,7 +519,7 @@ async function executeDomainAction( if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.'; if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`; const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId); - if (result.error) return `🚫 ${result.error}`; + if (isErrorResult(result)) return `🚫 ${result.error}`; return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`; } @@ -542,7 +555,8 @@ async function executeDomainAction( } } - return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${price?.toLocaleString()}원/년\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`; + const priceStr = price ? `${price.toLocaleString()}원/년` : '가격 조회 중'; + return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${priceStr}\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`; } return `❌ ${domain}은 이미 등록된 도메인입니다.`; } @@ -635,7 +649,9 @@ async function executeDomainAction( if (statuses.length) response += `• 상태: ${statuses.join(', ')}\n`; if (dnssec !== '-') response += `• DNSSEC: ${dnssec}`; - if (result.available === true) { + // Type guard to check if result has 'available' property + const typedResult = result as { available?: boolean }; + if (typedResult.available === true) { response += `\n\n✅ 이 도메인은 등록 가능합니다!`; } @@ -657,16 +673,19 @@ async function executeDomainAction( // API 호출 const result = await callNamecheapApi('get_price', { tld: targetTld }, allowedDomains, env, telegramUserId, db, userId); - if (result.error) return `🚫 ${result.error}`; + if (isErrorResult(result)) return `🚫 ${result.error}`; + + // Type assertion after error check + const priceResult = result as NamecheapPriceResponse; // 캐시 저장 if (env?.RATE_LIMIT_KV) { - await setCachedTLDPrice(env.RATE_LIMIT_KV, targetTld, result); + await setCachedTLDPrice(env.RATE_LIMIT_KV, targetTld, priceResult); } // API 응답: { tld, usd, krw } - const price = result.krw || result.register_krw; - return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${price?.toLocaleString()}원/년`; + const price = priceResult.krw ?? priceResult.register_krw ?? 0; + return `💰 .${targetTld} 도메인 가격\n\n• 등록/갱신: ${price.toLocaleString()}원/년`; } case 'cheapest': { @@ -719,14 +738,18 @@ async function executeDomainAction( // 1. 가용성 확인 const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId); - if (checkResult.error) return `🚫 ${checkResult.error}`; - if (!checkResult[domain]) return `❌ ${domain}은 이미 등록된 도메인입니다.`; + if (isErrorResult(checkResult)) return `🚫 ${checkResult.error}`; + + const availability = checkResult as NamecheapCheckResult; + if (!availability[domain]) return `❌ ${domain}은 이미 등록된 도메인입니다.`; // 2. 가격 조회 const domainTld = domain.split('.').pop() || ''; const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId); - if (priceResult.error) return `🚫 가격 조회 실패: ${priceResult.error}`; - const price = priceResult.krw || priceResult.register_krw; + if (isErrorResult(priceResult)) return `🚫 가격 조회 실패: ${priceResult.error}`; + + const priceData = priceResult as NamecheapPriceResponse; + const price = priceData.krw ?? priceData.register_krw ?? 0; // 3. 잔액 조회 let balance = 0; @@ -846,6 +869,9 @@ export async function executeSuggestDomains(args: { keywords: string }, env?: En return '🚫 도메인 추천 기능이 설정되지 않았습니다. (NAMECHEAP_API_KEY 미설정)'; } + // Store API key after null check + const namecheapApiKey = env.NAMECHEAP_API_KEY; + try { const namecheapApiUrl = env.NAMECHEAP_API_URL || 'https://namecheap-api.anvil.it.com'; const TARGET_COUNT = 10; @@ -926,7 +952,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''} () => fetch(`${namecheapApiUrl}/domains/check`, { method: 'POST', headers: { - 'X-API-Key': env.NAMECHEAP_API_KEY!, // Already checked above + 'X-API-Key': namecheapApiKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ domains: newDomains }), @@ -971,7 +997,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''} // 캐시 미스 시 API 호출 const priceRes = await retryWithBackoff( () => fetch(`${namecheapApiUrl}/prices/${tld}`, { - headers: { 'X-API-Key': env.NAMECHEAP_API_KEY! }, // Already checked above + headers: { 'X-API-Key': namecheapApiKey }, }), { maxRetries: 3 } ); diff --git a/src/tools/index.ts b/src/tools/index.ts index db2044a..80dc0c0 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -117,7 +117,7 @@ export function selectToolsForMessage(message: string): typeof tools { // Tool execution dispatcher with validation export async function executeTool( name: string, - args: Record, + args: Record, env?: Env, telegramUserId?: string, db?: D1Database diff --git a/src/utils/optimistic-lock.ts b/src/utils/optimistic-lock.ts index 35ba8a6..bb36bbb 100644 --- a/src/utils/optimistic-lock.ts +++ b/src/utils/optimistic-lock.ts @@ -39,9 +39,11 @@ export class OptimisticLockError extends Error { constructor(message: string) { super(message); this.name = 'OptimisticLockError'; - // Maintain proper stack trace for debugging - if (Error.captureStackTrace) { - Error.captureStackTrace(this, OptimisticLockError); + // Maintain proper stack trace for debugging (Node.js only, not available in Workers) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof (Error as any).captureStackTrace === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Error as any).captureStackTrace(this, OptimisticLockError); } } } @@ -56,12 +58,10 @@ export class OptimisticLockError extends Error { * @throws Error if all retries exhausted or non-OptimisticLockError occurs */ export async function executeWithOptimisticLock( - db: D1Database, + _db: D1Database, operation: (attempt: number) => Promise, maxRetries: number = 3 ): Promise { - let lastError: Error | undefined; - for (let attempt = 1; attempt <= maxRetries; attempt++) { try { logger.info(`Optimistic lock attempt ${attempt}/${maxRetries}`, { attempt }); @@ -79,7 +79,7 @@ export async function executeWithOptimisticLock( throw error; } - lastError = error; + // Store error for logging (optimistic lock conflict) if (attempt < maxRetries) { // Exponential backoff: 100ms, 200ms, 400ms