refactor: code quality improvements (P3)
## Type Safety
- Add zod runtime validation for external API responses
* Namecheap API responses (domain-register.ts)
* n8n webhook responses (n8n-service.ts)
* User request bodies (routes/api.ts)
* Replaced unsafe type assertions with safeParse()
* Proper error handling and logging
## Dead Code Removal
- Remove unused callDepositAgent function (127 lines)
* Legacy Assistants API code no longer needed
* Now using direct code execution
* File reduced from 469 → 345 lines (26.4% reduction)
## Configuration Management
- Extract hardcoded URLs to environment variables
* Added 7 new vars in wrangler.toml:
OPENAI_API_BASE, NAMECHEAP_API_URL, WHOIS_API_URL,
CONTEXT7_API_BASE, BRAVE_API_BASE, WTTR_IN_URL, HOSTING_SITE_URL
* Updated Env interface in types.ts
* All URLs have fallback to current production values
* Enables environment-specific configuration (dev/staging/prod)
## Dependencies
- Add zod 4.3.5 for runtime type validation
## Files Modified
- Configuration: wrangler.toml, types.ts, package.json
- Services: 11 TypeScript files with URL/validation updates
- Total: 15 files, +196/-189 lines
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "telegram-summary-bot",
|
"name": "telegram-summary-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20241127.0",
|
"@cloudflare/workers-types": "^4.20241127.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
@@ -1294,6 +1297,16 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/miniflare/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
||||||
@@ -1525,10 +1538,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -24,5 +24,8 @@
|
|||||||
"workers",
|
"workers",
|
||||||
"d1",
|
"d1",
|
||||||
"ai"
|
"ai"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,127 +343,3 @@ export async function executeDepositFunction(
|
|||||||
return { error: `알 수 없는 함수: ${funcName}` };
|
return { error: `알 수 없는 함수: ${funcName}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deposit Agent 호출 (Assistants API)
|
|
||||||
export async function callDepositAgent(
|
|
||||||
apiKey: string,
|
|
||||||
assistantId: string,
|
|
||||||
query: string,
|
|
||||||
context: DepositContext
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// 1. Thread 생성
|
|
||||||
const threadRes = await fetch('https://api.openai.com/v1/threads', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`;
|
|
||||||
const thread = await threadRes.json() as { id: string };
|
|
||||||
|
|
||||||
// 2. 메시지 추가 (권한 정보 포함)
|
|
||||||
const adminInfo = context.isAdmin ? '관리자 권한이 있습니다.' : '일반 사용자입니다.';
|
|
||||||
const instructions = `[시스템 정보]
|
|
||||||
- ${adminInfo}
|
|
||||||
- 사용자 ID: ${context.telegramUserId}
|
|
||||||
|
|
||||||
[사용자 요청]
|
|
||||||
${query}`;
|
|
||||||
|
|
||||||
await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
role: 'user',
|
|
||||||
content: instructions,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Run 생성
|
|
||||||
const runRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ assistant_id: assistantId }),
|
|
||||||
});
|
|
||||||
if (!runRes.ok) return `Run 생성 실패 (${runRes.status})`;
|
|
||||||
let run = await runRes.json() as { id: string; status: string; required_action?: any };
|
|
||||||
|
|
||||||
// 4. 완료까지 폴링 및 Function Calling 처리
|
|
||||||
let maxPolls = 30; // 최대 15초
|
|
||||||
while ((run.status === 'queued' || run.status === 'in_progress' || run.status === 'requires_action') && maxPolls > 0) {
|
|
||||||
if (run.status === 'requires_action') {
|
|
||||||
const toolCalls = run.required_action?.submit_tool_outputs?.tool_calls || [];
|
|
||||||
const toolOutputs = [];
|
|
||||||
|
|
||||||
for (const toolCall of toolCalls) {
|
|
||||||
const funcName = toolCall.function.name;
|
|
||||||
const funcArgs = JSON.parse(toolCall.function.arguments);
|
|
||||||
logger.info(`Function call: ${funcName}`, funcArgs);
|
|
||||||
|
|
||||||
const result = await executeDepositFunction(funcName, funcArgs, context);
|
|
||||||
toolOutputs.push({
|
|
||||||
tool_call_id: toolCall.id,
|
|
||||||
output: JSON.stringify(result),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool outputs 제출
|
|
||||||
const submitRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}/submit_tool_outputs`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ tool_outputs: toolOutputs }),
|
|
||||||
});
|
|
||||||
run = await submitRes.json() as { id: string; status: string; required_action?: any };
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
maxPolls--;
|
|
||||||
|
|
||||||
const statusRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
run = await statusRes.json() as { id: string; status: string; required_action?: any };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.status === 'failed') return '예치금 에이전트 실행 실패';
|
|
||||||
if (maxPolls === 0) return '응답 시간 초과. 다시 시도해주세요.';
|
|
||||||
|
|
||||||
// 5. 메시지 조회
|
|
||||||
const messagesRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'OpenAI-Beta': 'assistants=v2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const messages = await messagesRes.json() as { data: Array<{ role: string; content: Array<{ type: string; text?: { value: string } }> }> };
|
|
||||||
const lastMessage = messages.data[0];
|
|
||||||
|
|
||||||
if (lastMessage?.content?.[0]?.type === 'text') {
|
|
||||||
return lastMessage.content[0].text?.value || '응답 없음';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '예치금 에이전트 응답 없음';
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error', error as Error);
|
|
||||||
return `예치금 에이전트 오류: ${String(error)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
import { createLogger } from './utils/logger';
|
import { createLogger } from './utils/logger';
|
||||||
|
|
||||||
const logger = createLogger('domain-register');
|
const logger = createLogger('domain-register');
|
||||||
|
|
||||||
|
// Zod schemas for API response validation
|
||||||
|
const NamecheapRegisterResponseSchema = z.object({
|
||||||
|
registered: z.boolean().optional(),
|
||||||
|
domain: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
detail: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DomainInfoResponseSchema = z.object({
|
||||||
|
expires: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const NameserverResponseSchema = z.object({
|
||||||
|
nameservers: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
interface RegisterResult {
|
interface RegisterResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
@@ -22,7 +39,7 @@ export async function executeDomainRegister(
|
|||||||
price: number
|
price: number
|
||||||
): Promise<RegisterResult> {
|
): Promise<RegisterResult> {
|
||||||
const apiKey = env.NAMECHEAP_API_KEY;
|
const apiKey = env.NAMECHEAP_API_KEY;
|
||||||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
const apiUrl = env.NAMECHEAP_API_URL || 'https://namecheap-api.anvil.it.com';
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return { success: false, error: 'API 키가 설정되지 않았습니다.' };
|
return { success: false, error: 'API 키가 설정되지 않았습니다.' };
|
||||||
@@ -58,12 +75,15 @@ export async function executeDomainRegister(
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerResult = await registerResponse.json() as {
|
const jsonData = await registerResponse.json();
|
||||||
registered?: boolean;
|
const parseResult = NamecheapRegisterResponseSchema.safeParse(jsonData);
|
||||||
domain?: string;
|
|
||||||
error?: string;
|
if (!parseResult.success) {
|
||||||
detail?: string;
|
logger.error('Namecheap register response schema validation failed', parseResult.error);
|
||||||
};
|
return { success: false, error: '도메인 등록 응답 형식이 올바르지 않습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerResult = parseResult.data;
|
||||||
|
|
||||||
if (!registerResponse.ok || !registerResult.registered) {
|
if (!registerResponse.ok || !registerResult.registered) {
|
||||||
const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.';
|
const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.';
|
||||||
@@ -112,22 +132,36 @@ export async function executeDomainRegister(
|
|||||||
headers: { 'X-API-Key': apiKey }
|
headers: { 'X-API-Key': apiKey }
|
||||||
});
|
});
|
||||||
if (infoResponse.ok) {
|
if (infoResponse.ok) {
|
||||||
const infoResult = await infoResponse.json() as { expires?: string };
|
const infoJsonData = await infoResponse.json();
|
||||||
|
const infoParseResult = DomainInfoResponseSchema.safeParse(infoJsonData);
|
||||||
|
|
||||||
|
if (!infoParseResult.success) {
|
||||||
|
logger.warn('Domain info response schema validation failed', { domain });
|
||||||
|
} else {
|
||||||
|
const infoResult = infoParseResult.data;
|
||||||
if (infoResult.expires) {
|
if (infoResult.expires) {
|
||||||
// MM/DD/YYYY → YYYY-MM-DD 변환
|
// MM/DD/YYYY → YYYY-MM-DD 변환
|
||||||
const [month, day, year] = infoResult.expires.split('/');
|
const [month, day, year] = infoResult.expires.split('/');
|
||||||
expiresAt = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
expiresAt = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 네임서버 조회
|
// 네임서버 조회
|
||||||
const nsResponse = await fetch(`${apiUrl}/domains/${domain}/nameservers`, {
|
const nsResponse = await fetch(`${apiUrl}/domains/${domain}/nameservers`, {
|
||||||
headers: { 'X-API-Key': apiKey }
|
headers: { 'X-API-Key': apiKey }
|
||||||
});
|
});
|
||||||
if (nsResponse.ok) {
|
if (nsResponse.ok) {
|
||||||
const nsResult = await nsResponse.json() as { nameservers?: string[] };
|
const nsJsonData = await nsResponse.json();
|
||||||
|
const nsParseResult = NameserverResponseSchema.safeParse(nsJsonData);
|
||||||
|
|
||||||
|
if (!nsParseResult.success) {
|
||||||
|
logger.warn('Nameserver response schema validation failed', { domain });
|
||||||
|
} else {
|
||||||
|
const nsResult = nsParseResult.data;
|
||||||
nameservers = nsResult.nameservers || [];
|
nameservers = nsResult.nameservers || [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (infoError) {
|
} catch (infoError) {
|
||||||
console.log(`[DomainRegister] 도메인 정보 조회 실패 (무시):`, infoError);
|
console.log(`[DomainRegister] 도메인 정보 조회 실패 (무시):`, infoError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
import { Env, IntentAnalysis, N8nResponse } from './types';
|
import { Env, IntentAnalysis, N8nResponse } from './types';
|
||||||
|
import { createLogger } from './utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('n8n-service');
|
||||||
|
|
||||||
|
// Zod schema for N8n webhook response validation
|
||||||
|
const N8nResponseSchema = z.object({
|
||||||
|
reply: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// n8n으로 처리할 기능 목록 (참고용)
|
// n8n으로 처리할 기능 목록 (참고용)
|
||||||
// - weather: 날씨
|
// - weather: 날씨
|
||||||
@@ -104,8 +114,15 @@ export async function callN8n(
|
|||||||
return { error: `n8n 호출 실패 (${response.status})` };
|
return { error: `n8n 호출 실패 (${response.status})` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as N8nResponse;
|
const jsonData = await response.json();
|
||||||
return data;
|
const parseResult = N8nResponseSchema.safeParse(jsonData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
logger.error('N8n response schema validation failed', parseResult.error);
|
||||||
|
return { error: 'n8n 응답 형식 오류' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResult.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('n8n fetch error:', error);
|
console.error('n8n fetch error:', error);
|
||||||
return { error: 'n8n 연결 실패' };
|
return { error: 'n8n 연결 실패' };
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { metrics } from './utils/metrics';
|
|||||||
const logger = createLogger('openai');
|
const logger = createLogger('openai');
|
||||||
|
|
||||||
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
||||||
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
|
function getOpenAIUrl(env: Env): string {
|
||||||
|
const base = env.OPENAI_API_BASE || 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai';
|
||||||
|
return `${base}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
// Circuit Breaker 인스턴스 (전역 공유)
|
// Circuit Breaker 인스턴스 (전역 공유)
|
||||||
export const openaiCircuitBreaker = new CircuitBreaker({
|
export const openaiCircuitBreaker = new CircuitBreaker({
|
||||||
@@ -42,6 +45,7 @@ interface OpenAIResponse {
|
|||||||
|
|
||||||
// OpenAI API 호출 (retry + circuit breaker 적용)
|
// OpenAI API 호출 (retry + circuit breaker 적용)
|
||||||
async function callOpenAI(
|
async function callOpenAI(
|
||||||
|
env: Env,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
messages: OpenAIMessage[],
|
messages: OpenAIMessage[],
|
||||||
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
||||||
@@ -51,7 +55,7 @@ async function callOpenAI(
|
|||||||
try {
|
try {
|
||||||
return await retryWithBackoff(
|
return await retryWithBackoff(
|
||||||
async () => {
|
async () => {
|
||||||
const response = await fetch(OPENAI_API_URL, {
|
const response = await fetch(getOpenAIUrl(env), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -115,7 +119,7 @@ export async function generateOpenAIResponse(
|
|||||||
const selectedTools = selectToolsForMessage(userMessage);
|
const selectedTools = selectToolsForMessage(userMessage);
|
||||||
|
|
||||||
// 첫 번째 호출
|
// 첫 번째 호출
|
||||||
let response = await callOpenAI(apiKey, messages, selectedTools);
|
let response = await callOpenAI(env, apiKey, messages, selectedTools);
|
||||||
let assistantMessage = response.choices[0].message;
|
let assistantMessage = response.choices[0].message;
|
||||||
|
|
||||||
logger.info('tool_calls', {
|
logger.info('tool_calls', {
|
||||||
@@ -155,7 +159,7 @@ export async function generateOpenAIResponse(
|
|||||||
messages.push(...toolResults);
|
messages.push(...toolResults);
|
||||||
|
|
||||||
// 다시 호출 (도구 없이 응답 생성)
|
// 다시 호출 (도구 없이 응답 생성)
|
||||||
response = await callOpenAI(apiKey, messages, undefined);
|
response = await callOpenAI(env, apiKey, messages, undefined);
|
||||||
assistantMessage = response.choices[0].message;
|
assistantMessage = response.choices[0].message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +200,7 @@ export async function generateProfileWithOpenAI(
|
|||||||
// Circuit Breaker로 실행 감싸기
|
// Circuit Breaker로 실행 감싸기
|
||||||
return await openaiCircuitBreaker.execute(async () => {
|
return await openaiCircuitBreaker.execute(async () => {
|
||||||
const response = await callOpenAI(
|
const response = await callOpenAI(
|
||||||
|
env,
|
||||||
apiKey,
|
apiKey,
|
||||||
[{ role: 'user', content: prompt }],
|
[{ role: 'user', content: prompt }],
|
||||||
undefined // 도구 없이 호출
|
undefined // 도구 없이 호출
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
import { Env } from '../types';
|
import { Env } from '../types';
|
||||||
import { sendMessage } from '../telegram';
|
import { sendMessage } from '../telegram';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,26 @@ import { createLogger } from '../utils/logger';
|
|||||||
|
|
||||||
const logger = createLogger('api');
|
const logger = createLogger('api');
|
||||||
|
|
||||||
|
// Zod schemas for API request validation
|
||||||
|
const DepositDeductBodySchema = z.object({
|
||||||
|
telegram_id: z.string(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
reason: z.string(),
|
||||||
|
reference_id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestApiBodySchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
user_id: z.string().optional(),
|
||||||
|
secret: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContactFormBodySchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
message: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// 사용자 조회/생성
|
// 사용자 조회/생성
|
||||||
async function getOrCreateUser(
|
async function getOrCreateUser(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -117,20 +138,18 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
|
|||||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json() as {
|
const jsonData = await request.json();
|
||||||
telegram_id: string;
|
const parseResult = DepositDeductBodySchema.safeParse(jsonData);
|
||||||
amount: number;
|
|
||||||
reason: string;
|
|
||||||
reference_id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.telegram_id || !body.amount || !body.reason) {
|
if (!parseResult.success) {
|
||||||
return Response.json({ error: 'telegram_id, amount, reason required' }, { status: 400 });
|
logger.warn('Deposit deduct - Invalid request body', { errors: parseResult.error.issues });
|
||||||
|
return Response.json({
|
||||||
|
error: 'Invalid request body',
|
||||||
|
details: parseResult.error.issues
|
||||||
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.amount <= 0) {
|
const body = parseResult.data;
|
||||||
return Response.json({ error: 'Amount must be positive' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 조회
|
// 사용자 조회
|
||||||
const user = await env.DB.prepare(
|
const user = await env.DB.prepare(
|
||||||
@@ -203,7 +222,18 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
|
|||||||
// 테스트 API - 메시지 처리 후 응답 직접 반환
|
// 테스트 API - 메시지 처리 후 응답 직접 반환
|
||||||
if (url.pathname === '/api/test' && request.method === 'POST') {
|
if (url.pathname === '/api/test' && request.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await request.json() as { text: string; user_id?: string; secret?: string };
|
const jsonData = await request.json();
|
||||||
|
const parseResult = TestApiBodySchema.safeParse(jsonData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
logger.warn('Test API - Invalid request body', { errors: parseResult.error.issues });
|
||||||
|
return Response.json({
|
||||||
|
error: 'Invalid request body',
|
||||||
|
details: parseResult.error.issues
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parseResult.data;
|
||||||
|
|
||||||
// 간단한 인증
|
// 간단한 인증
|
||||||
if (body.secret !== env.WEBHOOK_SECRET) {
|
if (body.secret !== env.WEBHOOK_SECRET) {
|
||||||
@@ -261,15 +291,15 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
|
|||||||
// 문의 폼 API (웹사이트용)
|
// 문의 폼 API (웹사이트용)
|
||||||
if (url.pathname === '/api/contact' && request.method === 'POST') {
|
if (url.pathname === '/api/contact' && request.method === 'POST') {
|
||||||
// CORS: hosting.anvil.it.com만 허용
|
// CORS: hosting.anvil.it.com만 허용
|
||||||
|
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Origin 헤더 검증 (curl 우회 방지)
|
// Origin 헤더 검증 (curl 우회 방지)
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
const allowedOrigin = 'https://hosting.anvil.it.com';
|
|
||||||
|
|
||||||
if (!origin || origin !== allowedOrigin) {
|
if (!origin || origin !== allowedOrigin) {
|
||||||
logger.warn('Contact API - 허용되지 않은 Origin', { origin });
|
logger.warn('Contact API - 허용되지 않은 Origin', { origin });
|
||||||
@@ -280,20 +310,23 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json() as {
|
const jsonData = await request.json();
|
||||||
email: string;
|
const parseResult = ContactFormBodySchema.safeParse(jsonData);
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
if (!parseResult.success) {
|
||||||
if (!body.email || !body.message) {
|
logger.warn('Contact form - Invalid request body', { errors: parseResult.error.issues });
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: '이메일과 메시지는 필수 항목입니다.' },
|
{
|
||||||
|
error: '올바르지 않은 요청 형식입니다.',
|
||||||
|
details: parseResult.error.issues
|
||||||
|
},
|
||||||
{ status: 400, headers: corsHeaders }
|
{ status: 400, headers: corsHeaders }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이메일 형식 검증
|
const body = parseResult.data;
|
||||||
|
|
||||||
|
// 이메일 형식 검증 (Zod로 이미 검증됨, 추가 체크)
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(body.email)) {
|
if (!emailRegex.test(body.email)) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -341,9 +374,10 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
|
|||||||
|
|
||||||
// CORS preflight for contact API
|
// CORS preflight for contact API
|
||||||
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
|
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
|
||||||
|
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ async function handleMessage(
|
|||||||
|
|
||||||
// /start 명령어는 미니앱 버튼과 함께 전송
|
// /start 명령어는 미니앱 버튼과 함께 전송
|
||||||
if (command === '/start') {
|
if (command === '/start') {
|
||||||
|
const hostingUrl = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [
|
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [
|
||||||
[{ text: '🌐 서비스 보기', web_app: { url: 'https://hosting.anvil.it.com' } }],
|
[{ text: '🌐 서비스 보기', web_app: { url: hostingUrl } }],
|
||||||
[{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }],
|
[{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }],
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ ${text.slice(0, 500)}
|
|||||||
// 1. OpenAI 시도
|
// 1. OpenAI 시도
|
||||||
if (env.OPENAI_API_KEY) {
|
if (env.OPENAI_API_KEY) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions', {
|
const openaiBaseUrl = env.OPENAI_API_BASE || 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai';
|
||||||
|
const response = await fetch(`${openaiBaseUrl}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { createLogger, maskUserId } from '../utils/logger';
|
|||||||
const logger = createLogger('domain-tool');
|
const logger = createLogger('domain-tool');
|
||||||
|
|
||||||
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
||||||
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
|
function getOpenAIUrl(env: Env): string {
|
||||||
|
const base = env.OPENAI_API_BASE || 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai';
|
||||||
|
return `${base}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
// KV 캐싱 인터페이스
|
// KV 캐싱 인터페이스
|
||||||
interface CachedTLDPrice {
|
interface CachedTLDPrice {
|
||||||
@@ -157,7 +160,7 @@ async function callNamecheapApi(
|
|||||||
return { error: 'Namecheap API 키가 설정되지 않았습니다.' };
|
return { error: 'Namecheap API 키가 설정되지 않았습니다.' };
|
||||||
}
|
}
|
||||||
const apiKey = env.NAMECHEAP_API_KEY_INTERNAL;
|
const apiKey = env.NAMECHEAP_API_KEY_INTERNAL;
|
||||||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
const apiUrl = env.NAMECHEAP_API_URL || 'https://namecheap-api.anvil.it.com';
|
||||||
|
|
||||||
// 도메인 권한 체크 (쓰기 작업만)
|
// 도메인 권한 체크 (쓰기 작업만)
|
||||||
// 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능
|
// 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능
|
||||||
@@ -320,7 +323,7 @@ async function callNamecheapApi(
|
|||||||
const domain = funcArgs.domain;
|
const domain = funcArgs.domain;
|
||||||
try {
|
try {
|
||||||
const whoisRes = await retryWithBackoff(
|
const whoisRes = await retryWithBackoff(
|
||||||
() => fetch(`https://whois-api-kappa-inoutercoms-projects.vercel.app/api/whois/${domain}`),
|
() => fetch(`${env.WHOIS_API_URL || 'https://whois-api-kappa-inoutercoms-projects.vercel.app'}/api/whois/${domain}`),
|
||||||
{ maxRetries: 3 }
|
{ maxRetries: 3 }
|
||||||
);
|
);
|
||||||
if (!whoisRes.ok) {
|
if (!whoisRes.ok) {
|
||||||
@@ -778,7 +781,7 @@ export async function executeSuggestDomains(args: { keywords: string }, env?: En
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const namecheapApiUrl = 'https://namecheap-api.anvil.it.com';
|
const namecheapApiUrl = env.NAMECHEAP_API_URL || 'https://namecheap-api.anvil.it.com';
|
||||||
const TARGET_COUNT = 10;
|
const TARGET_COUNT = 10;
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
@@ -793,7 +796,7 @@ export async function executeSuggestDomains(args: { keywords: string }, env?: En
|
|||||||
|
|
||||||
// Step 1: GPT에게 도메인 아이디어 생성 요청
|
// Step 1: GPT에게 도메인 아이디어 생성 요청
|
||||||
const ideaResponse = await retryWithBackoff(
|
const ideaResponse = await retryWithBackoff(
|
||||||
() => fetch(OPENAI_API_URL, {
|
() => fetch(getOpenAIUrl(env), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ export async function executeTool(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'get_weather':
|
case 'get_weather':
|
||||||
return executeWeather(args as { city: string });
|
return executeWeather(args as { city: string }, env);
|
||||||
|
|
||||||
case 'search_web':
|
case 'search_web':
|
||||||
return executeSearchWeb(args as { query: string }, env);
|
return executeSearchWeb(args as { query: string }, env);
|
||||||
|
|
||||||
case 'lookup_docs':
|
case 'lookup_docs':
|
||||||
return executeLookupDocs(args as { library: string; query: string });
|
return executeLookupDocs(args as { library: string; query: string }, env);
|
||||||
|
|
||||||
case 'get_current_time':
|
case 'get_current_time':
|
||||||
return executeGetCurrentTime(args as { timezone?: string });
|
return executeGetCurrentTime(args as { timezone?: string });
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { createLogger } from '../utils/logger';
|
|||||||
const logger = createLogger('search-tool');
|
const logger = createLogger('search-tool');
|
||||||
|
|
||||||
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
||||||
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
|
function getOpenAIUrl(env: Env): string {
|
||||||
|
const base = env.OPENAI_API_BASE || 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai';
|
||||||
|
return `${base}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
export const searchWebTool = {
|
export const searchWebTool = {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@@ -61,7 +64,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
|||||||
if (hasKorean && env?.OPENAI_API_KEY) {
|
if (hasKorean && env?.OPENAI_API_KEY) {
|
||||||
try {
|
try {
|
||||||
const translateRes = await retryWithBackoff(
|
const translateRes = await retryWithBackoff(
|
||||||
() => fetch(OPENAI_API_URL, {
|
() => fetch(getOpenAIUrl(env), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -101,7 +104,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
|||||||
|
|
||||||
const response = await retryWithBackoff(
|
const response = await retryWithBackoff(
|
||||||
() => fetch(
|
() => fetch(
|
||||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
`${env.BRAVE_API_BASE || 'https://api.search.brave.com/res/v1'}/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -141,12 +144,12 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeLookupDocs(args: { library: string; query: string }): Promise<string> {
|
export async function executeLookupDocs(args: { library: string; query: string }, env?: Env): Promise<string> {
|
||||||
const { library, query } = args;
|
const { library, query } = args;
|
||||||
try {
|
try {
|
||||||
// Context7 REST API 직접 호출
|
// Context7 REST API 직접 호출
|
||||||
// 1. 라이브러리 검색
|
// 1. 라이브러리 검색
|
||||||
const searchUrl = `https://context7.com/api/v2/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`;
|
const searchUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`;
|
||||||
const searchResponse = await retryWithBackoff(
|
const searchResponse = await retryWithBackoff(
|
||||||
() => fetch(searchUrl),
|
() => fetch(searchUrl),
|
||||||
{ maxRetries: 3 }
|
{ maxRetries: 3 }
|
||||||
@@ -160,7 +163,7 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
|||||||
const libraryId = searchData.libraries[0].id;
|
const libraryId = searchData.libraries[0].id;
|
||||||
|
|
||||||
// 2. 문서 조회
|
// 2. 문서 조회
|
||||||
const docsUrl = `https://context7.com/api/v2/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
||||||
const docsResponse = await retryWithBackoff(
|
const docsResponse = await retryWithBackoff(
|
||||||
() => fetch(docsUrl),
|
() => fetch(docsUrl),
|
||||||
{ maxRetries: 3 }
|
{ maxRetries: 3 }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Weather Tool - wttr.in integration
|
// Weather Tool - wttr.in integration
|
||||||
|
import type { Env } from '../types';
|
||||||
|
|
||||||
export const weatherTool = {
|
export const weatherTool = {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@@ -18,11 +19,12 @@ export const weatherTool = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function executeWeather(args: { city: string }): Promise<string> {
|
export async function executeWeather(args: { city: string }, env?: Env): Promise<string> {
|
||||||
const city = args.city || 'Seoul';
|
const city = args.city || 'Seoul';
|
||||||
try {
|
try {
|
||||||
|
const wttrUrl = env?.WTTR_IN_URL || 'https://wttr.in';
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://wttr.in/${encodeURIComponent(city)}?format=j1`
|
`${wttrUrl}/${encodeURIComponent(city)}?format=j1`
|
||||||
);
|
);
|
||||||
const data = await response.json() as any;
|
const data = await response.json() as any;
|
||||||
const current = data.current_condition[0];
|
const current = data.current_condition[0];
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export interface Env {
|
|||||||
DEPOSIT_ADMIN_ID?: string;
|
DEPOSIT_ADMIN_ID?: string;
|
||||||
BRAVE_API_KEY?: string;
|
BRAVE_API_KEY?: string;
|
||||||
DEPOSIT_API_SECRET?: string;
|
DEPOSIT_API_SECRET?: string;
|
||||||
|
OPENAI_API_BASE?: string;
|
||||||
|
NAMECHEAP_API_URL?: string;
|
||||||
|
WHOIS_API_URL?: string;
|
||||||
|
CONTEXT7_API_BASE?: string;
|
||||||
|
BRAVE_API_BASE?: string;
|
||||||
|
WTTR_IN_URL?: string;
|
||||||
|
HOSTING_SITE_URL?: string;
|
||||||
RATE_LIMIT_KV: KVNamespace;
|
RATE_LIMIT_KV: KVNamespace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
|
|||||||
DOMAIN_OWNER_ID = "821596605" # 도메인 관리 권한 Telegram ID
|
DOMAIN_OWNER_ID = "821596605" # 도메인 관리 권한 Telegram ID
|
||||||
DEPOSIT_ADMIN_ID = "821596605" # 예치금 관리 권한 Telegram ID
|
DEPOSIT_ADMIN_ID = "821596605" # 예치금 관리 권한 Telegram ID
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai"
|
||||||
|
NAMECHEAP_API_URL = "https://namecheap-api.anvil.it.com"
|
||||||
|
WHOIS_API_URL = "https://whois-api-kappa-inoutercoms-projects.vercel.app"
|
||||||
|
CONTEXT7_API_BASE = "https://context7.com/api/v2"
|
||||||
|
BRAVE_API_BASE = "https://api.search.brave.com/res/v1"
|
||||||
|
WTTR_IN_URL = "https://wttr.in"
|
||||||
|
HOSTING_SITE_URL = "https://hosting.anvil.it.com"
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "telegram-conversations"
|
database_name = "telegram-conversations"
|
||||||
|
|||||||
Reference in New Issue
Block a user