refactor: add pattern utils and split api.ts into modules
1. Pattern Detection Utility (src/utils/patterns.ts) - Centralize tool category patterns (domain, deposit, server, etc.) - Add memory category patterns (company, tech, role) - Add region detection (korea, japan, singapore, us) - Add tech stack detection (postgres, redis, nodejs, etc.) - Export detectToolCategories(), detectRegion(), detectTechStack() 2. API Route Modularization (src/routes/api/) - deposit.ts: /balance, /deduct with apiKeyAuth middleware - chat.ts: /test, /chat with session handling - contact.ts: Contact form with CORS middleware - metrics.ts: Circuit Breaker status endpoint 3. Updates - tools/index.ts: Use detectToolCategories from patterns.ts - api.ts: Compose sub-routers (899 → 53 lines, 94% reduction) Benefits: - Single source of truth for patterns - Better code organization - Easier maintenance and testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,829 +1,19 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { cors } from 'hono/cors';
|
|
||||||
import { createMiddleware } from 'hono/factory';
|
|
||||||
import { Env } from '../types';
|
import { Env } from '../types';
|
||||||
import { sendMessage } from '../telegram';
|
import { depositRouter } from './api/deposit';
|
||||||
import {
|
import { chatRouter } from './api/chat';
|
||||||
addToBuffer,
|
import { contactRouter } from './api/contact';
|
||||||
processAndSummarize,
|
import { metricsRouter } from './api/metrics';
|
||||||
generateAIResponse,
|
|
||||||
} from '../summary-service';
|
|
||||||
import { handleCommand } from '../commands';
|
|
||||||
import { openaiCircuitBreaker } from '../openai-service';
|
|
||||||
import { createLogger } from '../utils/logger';
|
|
||||||
import { toError } from '../utils/error';
|
|
||||||
import { timingSafeEqual } from '../security';
|
|
||||||
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ChatApiBodySchema = z.object({
|
|
||||||
message: z.string(),
|
|
||||||
chat_id: z.number().optional(),
|
|
||||||
user_id: z.number().optional(),
|
|
||||||
username: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Key 인증 미들웨어 (Timing-safe comparison으로 타이밍 공격 방지)
|
|
||||||
* X-API-Key 헤더로 DEPOSIT_API_SECRET 검증
|
|
||||||
*/
|
|
||||||
const apiKeyAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
|
|
||||||
const apiSecret = c.env.DEPOSIT_API_SECRET;
|
|
||||||
const authHeader = c.req.header('X-API-Key');
|
|
||||||
|
|
||||||
if (!apiSecret || !timingSafeEqual(authHeader, apiSecret)) {
|
|
||||||
logger.warn('API Key 인증 실패', { hasApiKey: !!authHeader });
|
|
||||||
return c.json({ error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORS 헤더 생성
|
|
||||||
*/
|
|
||||||
function getCorsHeaders(env: Env): Record<string, string> {
|
|
||||||
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
|
||||||
return {
|
|
||||||
'Access-Control-Allow-Origin': allowedOrigin,
|
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 조회/생성
|
|
||||||
async function getOrCreateUser(
|
|
||||||
db: D1Database,
|
|
||||||
telegramId: string,
|
|
||||||
firstName: string,
|
|
||||||
username?: string
|
|
||||||
): Promise<number> {
|
|
||||||
const existing = await db
|
|
||||||
.prepare('SELECT id FROM users WHERE telegram_id = ?')
|
|
||||||
.bind(telegramId)
|
|
||||||
.first<{ id: number }>();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// 마지막 활동 시간 업데이트
|
|
||||||
await db
|
|
||||||
.prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|
||||||
.bind(existing.id)
|
|
||||||
.run();
|
|
||||||
return existing.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새 사용자 생성
|
|
||||||
const result = await db
|
|
||||||
.prepare('INSERT INTO users (telegram_id, first_name, username) VALUES (?, ?, ?)')
|
|
||||||
.bind(telegramId, firstName, username || null)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return result.meta.last_row_id as number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/deposit/balance - 잔액 조회 (namecheap-api 전용)
|
|
||||||
*
|
|
||||||
* @param env - Environment bindings
|
|
||||||
* @param url - Parsed URL
|
|
||||||
* @returns JSON response with balance
|
|
||||||
*/
|
|
||||||
async function handleDepositBalance(env: Env, url: URL): Promise<Response> {
|
|
||||||
try {
|
|
||||||
const telegramId = url.searchParams.get('telegram_id');
|
|
||||||
if (!telegramId) {
|
|
||||||
return Response.json({ error: 'telegram_id required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 조회
|
|
||||||
const user = await env.DB.prepare(
|
|
||||||
'SELECT id FROM users WHERE telegram_id = ?'
|
|
||||||
).bind(telegramId).first<{ id: number }>();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return Response.json({ error: 'User not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 잔액 조회
|
|
||||||
const deposit = await env.DB.prepare(
|
|
||||||
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
|
||||||
).bind(user.id).first<{ balance: number }>();
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
telegram_id: telegramId,
|
|
||||||
balance: deposit?.balance || 0,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Deposit balance error', toError(error));
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/deposit/deduct - 잔액 차감 (namecheap-api 전용)
|
|
||||||
*
|
|
||||||
* @param request - HTTP Request with body
|
|
||||||
* @param env - Environment bindings
|
|
||||||
* @returns JSON response with transaction result
|
|
||||||
*/
|
|
||||||
async function handleDepositDeduct(request: Request, env: Env): Promise<Response> {
|
|
||||||
try {
|
|
||||||
// JSON 파싱 (별도 에러 핸들링)
|
|
||||||
let jsonData: unknown;
|
|
||||||
try {
|
|
||||||
jsonData = await request.json();
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseResult = DepositDeductBodySchema.safeParse(jsonData);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
logger.warn('Deposit deduct - Invalid request body', { errors: parseResult.error.issues });
|
|
||||||
return Response.json({
|
|
||||||
error: 'Invalid request body',
|
|
||||||
details: parseResult.error.issues
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = parseResult.data;
|
|
||||||
|
|
||||||
// 사용자 조회
|
|
||||||
const user = await env.DB.prepare(
|
|
||||||
'SELECT id FROM users WHERE telegram_id = ?'
|
|
||||||
).bind(body.telegram_id).first<{ id: number }>();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return Response.json({ error: 'User not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 잔액과 version 확인 (Optimistic Locking)
|
|
||||||
const current = await env.DB.prepare(
|
|
||||||
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
|
|
||||||
).bind(user.id).first<{ balance: number; version: number }>();
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
return Response.json({ error: 'User deposit account not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.balance < body.amount) {
|
|
||||||
return Response.json({
|
|
||||||
error: 'Insufficient balance',
|
|
||||||
current_balance: current.balance,
|
|
||||||
required: body.amount,
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic Locking: version 조건으로 잔액 차감
|
|
||||||
const balanceUpdate = await env.DB.prepare(
|
|
||||||
'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
|
|
||||||
).bind(body.amount, user.id, current.version).run();
|
|
||||||
|
|
||||||
if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
|
|
||||||
logger.warn('Optimistic locking conflict (외부 API 잔액 차감)', {
|
|
||||||
userId: user.id,
|
|
||||||
telegram_id: body.telegram_id,
|
|
||||||
amount: body.amount,
|
|
||||||
expectedVersion: current.version,
|
|
||||||
context: 'api_deposit_deduct'
|
|
||||||
});
|
|
||||||
return Response.json({
|
|
||||||
error: 'Concurrent modification detected',
|
|
||||||
message: '동시 요청 감지 - 다시 시도해주세요'
|
|
||||||
}, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 거래 기록 INSERT
|
|
||||||
const transactionInsert = await env.DB.prepare(
|
|
||||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
|
|
||||||
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
|
|
||||||
).bind(user.id, body.amount, body.reason).run();
|
|
||||||
|
|
||||||
if (!transactionInsert.success) {
|
|
||||||
// 잔액 복구 시도 (rollback)
|
|
||||||
try {
|
|
||||||
await env.DB.prepare(
|
|
||||||
'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
|
||||||
).bind(body.amount, user.id).run();
|
|
||||||
|
|
||||||
logger.error('거래 기록 INSERT 실패 - 잔액 복구 완료', undefined, {
|
|
||||||
userId: user.id,
|
|
||||||
telegram_id: body.telegram_id,
|
|
||||||
amount: body.amount,
|
|
||||||
reason: body.reason,
|
|
||||||
context: 'api_deposit_deduct_rollback'
|
|
||||||
});
|
|
||||||
} catch (rollbackError) {
|
|
||||||
logger.error('잔액 복구 실패 - 수동 확인 필요', toError(rollbackError), {
|
|
||||||
userId: user.id,
|
|
||||||
telegram_id: body.telegram_id,
|
|
||||||
amount: body.amount,
|
|
||||||
context: 'api_deposit_deduct_rollback_failed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
error: 'Transaction processing failed',
|
|
||||||
message: '거래 처리 실패 - 관리자에게 문의하세요'
|
|
||||||
}, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBalance = current.balance - body.amount;
|
|
||||||
|
|
||||||
logger.info('Deposit deducted', {
|
|
||||||
telegram_id: body.telegram_id,
|
|
||||||
amount: body.amount,
|
|
||||||
reason: body.reason,
|
|
||||||
new_balance: newBalance
|
|
||||||
});
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
telegram_id: body.telegram_id,
|
|
||||||
deducted: body.amount,
|
|
||||||
previous_balance: current.balance,
|
|
||||||
new_balance: newBalance,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Deposit deduct error', toError(error));
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/test - 테스트 API (메시지 처리 후 응답 직접 반환)
|
|
||||||
*
|
|
||||||
* ⚠️ 개발 환경 전용 - 프로덕션에서는 비활성화
|
|
||||||
*
|
|
||||||
* @param request - HTTP Request with body
|
|
||||||
* @param env - Environment bindings
|
|
||||||
* @returns JSON response with AI response
|
|
||||||
*/
|
|
||||||
async function handleTestApi(request: Request, env: Env): Promise<Response> {
|
|
||||||
// 개발/테스트 환경에서만 활성화 (명시적 설정 필수)
|
|
||||||
if (env.ENVIRONMENT !== 'development' && env.ENVIRONMENT !== 'test') {
|
|
||||||
return new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// JSON 파싱 (별도 에러 핸들링)
|
|
||||||
let jsonData: unknown;
|
|
||||||
try {
|
|
||||||
jsonData = await request.json();
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 인증 (Timing-safe comparison 사용)
|
|
||||||
if (!timingSafeEqual(body.secret || '', env.WEBHOOK_SECRET || '')) {
|
|
||||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.text) {
|
|
||||||
return Response.json({ error: 'text required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramUserId = body.user_id || '821596605';
|
|
||||||
const chatIdStr = telegramUserId;
|
|
||||||
|
|
||||||
// 사용자 조회/생성
|
|
||||||
const userId = await getOrCreateUser(env.DB, telegramUserId, 'TestUser', 'testuser');
|
|
||||||
|
|
||||||
let responseText: string;
|
|
||||||
|
|
||||||
// 명령어 처리
|
|
||||||
if (body.text.startsWith('/')) {
|
|
||||||
const [command, ...argParts] = body.text.split(' ');
|
|
||||||
const args = argParts.join(' ');
|
|
||||||
responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
|
||||||
} else {
|
|
||||||
// 1. 사용자 메시지 버퍼에 추가
|
|
||||||
await addToBuffer(env.DB, userId, chatIdStr, 'user', body.text);
|
|
||||||
|
|
||||||
// 2. AI 응답 생성
|
|
||||||
responseText = await generateAIResponse(env, userId, chatIdStr, body.text, telegramUserId);
|
|
||||||
|
|
||||||
// __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비)
|
|
||||||
if (responseText.includes('__DIRECT__')) {
|
|
||||||
const directIndex = responseText.indexOf('__DIRECT__');
|
|
||||||
responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 봇 응답 버퍼에 추가
|
|
||||||
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
|
||||||
|
|
||||||
// 4. 임계값 도달시 프로필 업데이트
|
|
||||||
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
|
|
||||||
if (summarized) {
|
|
||||||
responseText += '\n\n👤 프로필이 업데이트되었습니다.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML 태그 제거 (CLI 출력용)
|
|
||||||
const plainText = responseText.replace(/<[^>]*>/g, '');
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
input: body.text,
|
|
||||||
response: plainText,
|
|
||||||
user_id: telegramUserId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Test API error', toError(error));
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/chat - 인증된 채팅 API (프로덕션 활성화)
|
|
||||||
*
|
|
||||||
* @param request - HTTP Request with body
|
|
||||||
* @param env - Environment bindings
|
|
||||||
* @returns JSON response with AI response
|
|
||||||
*/
|
|
||||||
async function handleChatApi(request: Request, env: Env): Promise<Response> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Bearer Token 인증 (Timing-safe comparison으로 타이밍 공격 방지)
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`;
|
|
||||||
if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) {
|
|
||||||
logger.warn('Chat API - Unauthorized access attempt', { hasAuthHeader: !!authHeader });
|
|
||||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON 파싱 (별도 에러 핸들링)
|
|
||||||
let jsonData: unknown;
|
|
||||||
try {
|
|
||||||
jsonData = await request.json();
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseResult = ChatApiBodySchema.safeParse(jsonData);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
logger.warn('Chat 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.message) {
|
|
||||||
return Response.json({ error: 'message required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본값 설정
|
|
||||||
const telegramUserId = body.user_id?.toString() || '821596605';
|
|
||||||
const chatId = body.chat_id || 821596605;
|
|
||||||
const chatIdStr = chatId.toString();
|
|
||||||
const username = body.username || 'web-tester';
|
|
||||||
|
|
||||||
// 사용자 조회/생성
|
|
||||||
const userId = await getOrCreateUser(env.DB, telegramUserId, 'WebUser', username);
|
|
||||||
|
|
||||||
let responseText: string;
|
|
||||||
|
|
||||||
// 서버 삭제 확인 처리 (텍스트 기반)
|
|
||||||
if (body.message.trim() === '삭제') {
|
|
||||||
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
|
|
||||||
const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
|
|
||||||
|
|
||||||
if (deleteSessionData) {
|
|
||||||
try {
|
|
||||||
const { orderId } = JSON.parse(deleteSessionData);
|
|
||||||
|
|
||||||
// Import and execute server deletion
|
|
||||||
const { executeServerDelete } = await import('../tools/server-tool');
|
|
||||||
const result = await executeServerDelete(orderId, telegramUserId, env);
|
|
||||||
|
|
||||||
// Delete session after execution
|
|
||||||
await env.SESSION_KV.delete(deleteSessionKey);
|
|
||||||
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: result.message,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Chat API - 서버 삭제 처리 오류', toError(error));
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.',
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버 삭제 취소 처리 (다른 메시지 입력 시)
|
|
||||||
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
|
|
||||||
const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
|
|
||||||
|
|
||||||
if (deleteSessionData && body.message.trim() !== '삭제') {
|
|
||||||
try {
|
|
||||||
const { label } = JSON.parse(deleteSessionData);
|
|
||||||
await env.SESSION_KV.delete(deleteSessionKey);
|
|
||||||
|
|
||||||
// Don't show cancellation message if it's a command (let command handler process it)
|
|
||||||
if (!body.message.startsWith('/')) {
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Chat API - 삭제 세션 취소 오류', toError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버 신청 확인 처리 (텍스트 기반) - Queue 기반
|
|
||||||
if (body.message.trim() === '신청') {
|
|
||||||
const orderSessionKey = `server_order_confirm:${telegramUserId}`;
|
|
||||||
logger.info('신청 세션 확인', { orderSessionKey, telegramUserId });
|
|
||||||
const orderSessionData = await env.SESSION_KV.get(orderSessionKey);
|
|
||||||
logger.info('신청 세션 데이터', { found: !!orderSessionData, data: orderSessionData?.slice(0, 100) });
|
|
||||||
|
|
||||||
if (orderSessionData) {
|
|
||||||
try {
|
|
||||||
const orderData = JSON.parse(orderSessionData);
|
|
||||||
|
|
||||||
// 1. 서버 세션에서 가격 정보 가져오기
|
|
||||||
const { getServerSession, deleteServerSession } = await import('../server-agent');
|
|
||||||
const session = await getServerSession(env.DB, telegramUserId);
|
|
||||||
|
|
||||||
if (!session || !session.lastRecommendation) {
|
|
||||||
await env.SESSION_KV.delete(orderSessionKey);
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.',
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = session.lastRecommendation.recommendations[orderData.index];
|
|
||||||
if (!selected) {
|
|
||||||
await env.SESSION_KV.delete(orderSessionKey);
|
|
||||||
await deleteServerSession(env.DB, telegramUserId);
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: '❌ 선택한 서버를 찾을 수 없습니다.',
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const price = selected.price?.monthly_krw || 0;
|
|
||||||
|
|
||||||
// 2. 잔액 확인
|
|
||||||
const deposit = await env.DB.prepare(
|
|
||||||
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
|
||||||
).bind(userId).first<{ balance: number }>();
|
|
||||||
|
|
||||||
if (!deposit || deposit.balance < price) {
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response:
|
|
||||||
`❌ 잔액이 부족합니다.\n\n` +
|
|
||||||
`• 서버 가격: ${price.toLocaleString()}원/월\n` +
|
|
||||||
`• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` +
|
|
||||||
`• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` +
|
|
||||||
`잔액을 충전 후 다시 시도해주세요.`,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Queue 확인
|
|
||||||
if (!env.SERVER_PROVISION_QUEUE) {
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.',
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 주문 생성 및 Queue 전송
|
|
||||||
const { createServerOrder, sendProvisionMessage } = await import('../server-provision');
|
|
||||||
|
|
||||||
const orderId = await createServerOrder(
|
|
||||||
env.DB,
|
|
||||||
userId,
|
|
||||||
telegramUserId,
|
|
||||||
selected.pricing_id,
|
|
||||||
selected.region.code,
|
|
||||||
'anvil',
|
|
||||||
price,
|
|
||||||
`${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId);
|
|
||||||
|
|
||||||
// 5. 세션 정리
|
|
||||||
await env.SESSION_KV.delete(orderSessionKey);
|
|
||||||
await deleteServerSession(env.DB, telegramUserId);
|
|
||||||
|
|
||||||
// 6. 즉시 응답
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response:
|
|
||||||
`📋 <b>서버 주문 접수 완료!</b> (주문 #${orderId})\n\n` +
|
|
||||||
`• 서버: ${selected.plan_name}\n` +
|
|
||||||
`• 리전: ${selected.region.name} (${selected.region.code})\n` +
|
|
||||||
`• 가격: ${price.toLocaleString()}원/월\n\n` +
|
|
||||||
`⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` +
|
|
||||||
`완료되면 메시지로 알려드릴게요.`,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Chat API - 서버 신청 처리 오류', toError(error));
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: '🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.',
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버 신청 취소 처리 (다른 메시지 입력 시)
|
|
||||||
const orderSessionKey = `server_order_confirm:${telegramUserId}`;
|
|
||||||
const orderSessionData = await env.SESSION_KV.get(orderSessionKey);
|
|
||||||
|
|
||||||
if (orderSessionData && body.message.trim() !== '신청') {
|
|
||||||
try {
|
|
||||||
const { plan } = JSON.parse(orderSessionData);
|
|
||||||
await env.SESSION_KV.delete(orderSessionKey);
|
|
||||||
|
|
||||||
// Don't show cancellation message if it's a command
|
|
||||||
if (!body.message.startsWith('/')) {
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: `⏹️ 서버 신청이 취소되었습니다.\n\n신청하려던 서버: ${plan}`,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Chat API - 신청 세션 취소 오류', toError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 명령어 처리
|
|
||||||
if (body.message.startsWith('/')) {
|
|
||||||
const [command, ...argParts] = body.message.split(' ');
|
|
||||||
const args = argParts.join(' ');
|
|
||||||
responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
|
||||||
} else {
|
|
||||||
// 1. 사용자 메시지 버퍼에 추가
|
|
||||||
await addToBuffer(env.DB, userId, chatIdStr, 'user', body.message);
|
|
||||||
|
|
||||||
// 2. AI 응답 생성
|
|
||||||
responseText = await generateAIResponse(env, userId, chatIdStr, body.message, telegramUserId);
|
|
||||||
|
|
||||||
// __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비)
|
|
||||||
if (responseText.includes('__DIRECT__')) {
|
|
||||||
const directIndex = responseText.indexOf('__DIRECT__');
|
|
||||||
responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 봇 응답 버퍼에 추가
|
|
||||||
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
|
||||||
|
|
||||||
// 4. 임계값 도달시 프로필 업데이트
|
|
||||||
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
|
|
||||||
if (summarized) {
|
|
||||||
responseText += '\n\n👤 프로필이 업데이트되었습니다.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
logger.info('Chat API request processed', {
|
|
||||||
user_id: telegramUserId,
|
|
||||||
username,
|
|
||||||
message_length: body.message.length,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
response: responseText,
|
|
||||||
processing_time_ms: processingTimeMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const processingTimeMs = Date.now() - startTime;
|
|
||||||
logger.error('Chat API error', toError(error), { processing_time_ms: processingTimeMs });
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/contact - 문의 폼 API (웹사이트용)
|
|
||||||
*
|
|
||||||
* @param request - HTTP Request with body
|
|
||||||
* @param env - Environment bindings
|
|
||||||
* @returns JSON response with success status
|
|
||||||
*/
|
|
||||||
async function handleContactForm(request: Request, env: Env): Promise<Response> {
|
|
||||||
// CORS 헤더 생성
|
|
||||||
const corsHeaders = getCorsHeaders(env);
|
|
||||||
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
|
||||||
|
|
||||||
// Origin 헤더 검증 (curl 우회 방지)
|
|
||||||
const origin = request.headers.get('Origin');
|
|
||||||
|
|
||||||
if (!origin || origin !== allowedOrigin) {
|
|
||||||
logger.warn('Contact API - 허용되지 않은 Origin', { origin });
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'Forbidden' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// JSON 파싱 (별도 에러 핸들링)
|
|
||||||
let jsonData: unknown;
|
|
||||||
try {
|
|
||||||
jsonData = await request.json();
|
|
||||||
} catch {
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'Invalid JSON format' },
|
|
||||||
{ status: 400, headers: corsHeaders }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseResult = ContactFormBodySchema.safeParse(jsonData);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
logger.warn('Contact form - Invalid request body', { errors: parseResult.error.issues });
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: '올바르지 않은 요청 형식입니다.',
|
|
||||||
details: parseResult.error.issues
|
|
||||||
},
|
|
||||||
{ status: 400, headers: corsHeaders }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = parseResult.data;
|
|
||||||
|
|
||||||
// 메시지 길이 제한
|
|
||||||
if (body.message.length > 2000) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: '메시지는 2000자 이내로 작성해주세요.' },
|
|
||||||
{ status: 400, headers: corsHeaders }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 관리자에게 텔레그램 알림
|
|
||||||
const adminId = env.DEPOSIT_ADMIN_ID || env.DOMAIN_OWNER_ID;
|
|
||||||
if (env.BOT_TOKEN && adminId) {
|
|
||||||
const timestamp = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
|
||||||
await sendMessage(
|
|
||||||
env.BOT_TOKEN,
|
|
||||||
parseInt(adminId),
|
|
||||||
`📬 <b>웹사이트 문의</b>\n\n` +
|
|
||||||
`📧 이메일: <code>${body.email}</code>\n` +
|
|
||||||
`🕐 시간: ${timestamp}\n\n` +
|
|
||||||
`💬 내용:\n${body.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('문의 수신', { email: body.email, hasName: !!body.name });
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{ success: true, message: '문의가 성공적으로 전송되었습니다.' },
|
|
||||||
{ headers: corsHeaders }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Contact form error', toError(error));
|
|
||||||
return Response.json(
|
|
||||||
{ error: '문의 전송 중 오류가 발생했습니다.' },
|
|
||||||
{ status: 500, headers: corsHeaders }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/metrics - Circuit Breaker 상태 조회 (관리자 전용)
|
|
||||||
*
|
|
||||||
* @param request - HTTP Request
|
|
||||||
* @param env - Environment bindings
|
|
||||||
* @returns JSON response with metrics
|
|
||||||
*/
|
|
||||||
async function handleMetrics(request: Request, env: Env): Promise<Response> {
|
|
||||||
try {
|
|
||||||
// WEBHOOK_SECRET 인증 (Timing-safe comparison으로 타이밍 공격 방지)
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`;
|
|
||||||
if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) {
|
|
||||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Circuit Breaker 상태 수집
|
|
||||||
const openaiStats = openaiCircuitBreaker.getStats();
|
|
||||||
|
|
||||||
// 메트릭 응답 생성
|
|
||||||
const metrics = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
circuitBreakers: {
|
|
||||||
openai: {
|
|
||||||
state: openaiStats.state,
|
|
||||||
failures: openaiStats.failures,
|
|
||||||
lastFailureTime: openaiStats.lastFailureTime?.toISOString(),
|
|
||||||
stats: openaiStats.stats,
|
|
||||||
config: openaiStats.config,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 추후 확장 가능: API 호출 통계, 캐시 hit rate 등
|
|
||||||
metrics: {
|
|
||||||
api_calls: {
|
|
||||||
// 추후 구현: 실제 API 호출 통계
|
|
||||||
openai: { count: openaiStats.stats.totalRequests, avg_duration: 0 },
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
// 추후 구현: 에러 통계
|
|
||||||
retry_exhausted: 0,
|
|
||||||
circuit_breaker_open: openaiStats.state === 'OPEN' ? 1 : 0,
|
|
||||||
},
|
|
||||||
cache: {
|
|
||||||
// 추후 구현: 캐시 hit rate
|
|
||||||
hit_rate: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('Metrics retrieved', {
|
|
||||||
state: openaiStats.state,
|
|
||||||
failures: openaiStats.failures,
|
|
||||||
requests: openaiStats.stats.totalRequests
|
|
||||||
});
|
|
||||||
|
|
||||||
return Response.json(metrics);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Metrics API error', toError(error));
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Router (Hono)
|
* API Router (Hono)
|
||||||
*
|
*
|
||||||
|
* Organized into sub-modules for maintainability:
|
||||||
|
* - /deposit/* - Deposit balance & deduction (deposit.ts)
|
||||||
|
* - /test, /chat - Test & Chat APIs (chat.ts)
|
||||||
|
* - /contact - Contact form (contact.ts)
|
||||||
|
* - /metrics - Circuit Breaker metrics (metrics.ts)
|
||||||
|
*
|
||||||
* Manual Test:
|
* Manual Test:
|
||||||
* 1. wrangler dev
|
* 1. wrangler dev
|
||||||
* 2. Test deposit balance:
|
* 2. Test deposit balance:
|
||||||
@@ -854,45 +44,10 @@ async function handleMetrics(request: Request, env: Env): Promise<Response> {
|
|||||||
*/
|
*/
|
||||||
const api = new Hono<{ Bindings: Env }>();
|
const api = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
// CORS middleware for /contact endpoint
|
// Mount sub-routers
|
||||||
api.use('/contact', cors({
|
api.route('/deposit', depositRouter);
|
||||||
origin: (origin, c) => {
|
api.route('/', chatRouter); // /test, /chat
|
||||||
const allowedOrigin = c.env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
api.route('/contact', contactRouter);
|
||||||
return origin === allowedOrigin ? origin : null;
|
api.route('/metrics', metricsRouter);
|
||||||
},
|
|
||||||
allowMethods: ['POST', 'OPTIONS'],
|
|
||||||
allowHeaders: ['Content-Type'],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// GET /deposit/balance - 잔액 조회 (namecheap-api 전용, API Key 필요)
|
|
||||||
api.get('/deposit/balance', apiKeyAuth, async (c) => {
|
|
||||||
const url = new URL(c.req.url);
|
|
||||||
return await handleDepositBalance(c.env, url);
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /deposit/deduct - 잔액 차감 (namecheap-api 전용, API Key 필요)
|
|
||||||
api.post('/deposit/deduct', apiKeyAuth, async (c) => {
|
|
||||||
return await handleDepositDeduct(c.req.raw, c.env);
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /test - 테스트 API (개발 환경 전용)
|
|
||||||
api.post('/test', async (c) => {
|
|
||||||
return await handleTestApi(c.req.raw, c.env);
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /chat - 인증된 채팅 API (프로덕션)
|
|
||||||
api.post('/chat', async (c) => {
|
|
||||||
return await handleChatApi(c.req.raw, c.env);
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /contact - 문의 폼 API (웹사이트용)
|
|
||||||
api.post('/contact', async (c) => {
|
|
||||||
return await handleContactForm(c.req.raw, c.env);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /metrics - Circuit Breaker 상태 조회 (관리자 전용)
|
|
||||||
api.get('/metrics', async (c) => {
|
|
||||||
return await handleMetrics(c.req.raw, c.env);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { api as apiRouter };
|
export { api as apiRouter };
|
||||||
|
|||||||
473
src/routes/api/chat.ts
Normal file
473
src/routes/api/chat.ts
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { Env } from '../../types';
|
||||||
|
import {
|
||||||
|
addToBuffer,
|
||||||
|
processAndSummarize,
|
||||||
|
generateAIResponse,
|
||||||
|
} from '../../summary-service';
|
||||||
|
import { handleCommand } from '../../commands';
|
||||||
|
import { timingSafeEqual } from '../../security';
|
||||||
|
import { createLogger } from '../../utils/logger';
|
||||||
|
import { toError } from '../../utils/error';
|
||||||
|
|
||||||
|
const logger = createLogger('api-chat');
|
||||||
|
|
||||||
|
// Zod schemas for API request validation
|
||||||
|
const TestApiBodySchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
user_id: z.string().optional(),
|
||||||
|
secret: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChatApiBodySchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
chat_id: z.number().optional(),
|
||||||
|
user_id: z.number().optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 조회/생성 헬퍼 함수
|
||||||
|
*/
|
||||||
|
async function getOrCreateUser(
|
||||||
|
db: D1Database,
|
||||||
|
telegramId: string,
|
||||||
|
firstName: string,
|
||||||
|
username?: string
|
||||||
|
): Promise<number> {
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT id FROM users WHERE telegram_id = ?')
|
||||||
|
.bind(telegramId)
|
||||||
|
.first<{ id: number }>();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 마지막 활동 시간 업데이트
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.bind(existing.id)
|
||||||
|
.run();
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 사용자 생성
|
||||||
|
const result = await db
|
||||||
|
.prepare('INSERT INTO users (telegram_id, first_name, username) VALUES (?, ?, ?)')
|
||||||
|
.bind(telegramId, firstName, username || null)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return result.meta.last_row_id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /test - 테스트 API (메시지 처리 후 응답 직접 반환)
|
||||||
|
*
|
||||||
|
* ⚠️ 개발 환경 전용 - 프로덕션에서는 비활성화
|
||||||
|
*
|
||||||
|
* @param request - HTTP Request with body
|
||||||
|
* @param env - Environment bindings
|
||||||
|
* @returns JSON response with AI response
|
||||||
|
*/
|
||||||
|
async function handleTestApi(request: Request, env: Env): Promise<Response> {
|
||||||
|
// 개발/테스트 환경에서만 활성화 (명시적 설정 필수)
|
||||||
|
if (env.ENVIRONMENT !== 'development' && env.ENVIRONMENT !== 'test') {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// JSON 파싱 (별도 에러 핸들링)
|
||||||
|
let jsonData: unknown;
|
||||||
|
try {
|
||||||
|
jsonData = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 인증 (Timing-safe comparison 사용)
|
||||||
|
if (!timingSafeEqual(body.secret || '', env.WEBHOOK_SECRET || '')) {
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.text) {
|
||||||
|
return Response.json({ error: 'text required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramUserId = body.user_id || '821596605';
|
||||||
|
const chatIdStr = telegramUserId;
|
||||||
|
|
||||||
|
// 사용자 조회/생성
|
||||||
|
const userId = await getOrCreateUser(env.DB, telegramUserId, 'TestUser', 'testuser');
|
||||||
|
|
||||||
|
let responseText: string;
|
||||||
|
|
||||||
|
// 명령어 처리
|
||||||
|
if (body.text.startsWith('/')) {
|
||||||
|
const [command, ...argParts] = body.text.split(' ');
|
||||||
|
const args = argParts.join(' ');
|
||||||
|
responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
||||||
|
} else {
|
||||||
|
// 1. 사용자 메시지 버퍼에 추가
|
||||||
|
await addToBuffer(env.DB, userId, chatIdStr, 'user', body.text);
|
||||||
|
|
||||||
|
// 2. AI 응답 생성
|
||||||
|
responseText = await generateAIResponse(env, userId, chatIdStr, body.text, telegramUserId);
|
||||||
|
|
||||||
|
// __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비)
|
||||||
|
if (responseText.includes('__DIRECT__')) {
|
||||||
|
const directIndex = responseText.indexOf('__DIRECT__');
|
||||||
|
responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 봇 응답 버퍼에 추가
|
||||||
|
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
||||||
|
|
||||||
|
// 4. 임계값 도달시 프로필 업데이트
|
||||||
|
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
|
||||||
|
if (summarized) {
|
||||||
|
responseText += '\n\n👤 프로필이 업데이트되었습니다.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 태그 제거 (CLI 출력용)
|
||||||
|
const plainText = responseText.replace(/<[^>]*>/g, '');
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
input: body.text,
|
||||||
|
response: plainText,
|
||||||
|
user_id: telegramUserId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Test API error', toError(error));
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /chat - 인증된 채팅 API (프로덕션 활성화)
|
||||||
|
*
|
||||||
|
* @param request - HTTP Request with body
|
||||||
|
* @param env - Environment bindings
|
||||||
|
* @returns JSON response with AI response
|
||||||
|
*/
|
||||||
|
async function handleChatApi(request: Request, env: Env): Promise<Response> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Bearer Token 인증 (Timing-safe comparison으로 타이밍 공격 방지)
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`;
|
||||||
|
if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) {
|
||||||
|
logger.warn('Chat API - Unauthorized access attempt', { hasAuthHeader: !!authHeader });
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 파싱 (별도 에러 핸들링)
|
||||||
|
let jsonData: unknown;
|
||||||
|
try {
|
||||||
|
jsonData = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = ChatApiBodySchema.safeParse(jsonData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
logger.warn('Chat 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.message) {
|
||||||
|
return Response.json({ error: 'message required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값 설정
|
||||||
|
const telegramUserId = body.user_id?.toString() || '821596605';
|
||||||
|
const chatId = body.chat_id || 821596605;
|
||||||
|
const chatIdStr = chatId.toString();
|
||||||
|
const username = body.username || 'web-tester';
|
||||||
|
|
||||||
|
// 사용자 조회/생성
|
||||||
|
const userId = await getOrCreateUser(env.DB, telegramUserId, 'WebUser', username);
|
||||||
|
|
||||||
|
let responseText: string;
|
||||||
|
|
||||||
|
// 서버 삭제 확인 처리 (텍스트 기반)
|
||||||
|
if (body.message.trim() === '삭제') {
|
||||||
|
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
|
||||||
|
const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
|
||||||
|
|
||||||
|
if (deleteSessionData) {
|
||||||
|
try {
|
||||||
|
const { orderId } = JSON.parse(deleteSessionData);
|
||||||
|
|
||||||
|
// Import and execute server deletion
|
||||||
|
const { executeServerDelete } = await import('../../tools/server-tool');
|
||||||
|
const result = await executeServerDelete(orderId, telegramUserId, env);
|
||||||
|
|
||||||
|
// Delete session after execution
|
||||||
|
await env.SESSION_KV.delete(deleteSessionKey);
|
||||||
|
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: result.message,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Chat API - 서버 삭제 처리 오류', toError(error));
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.',
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 삭제 취소 처리 (다른 메시지 입력 시)
|
||||||
|
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
|
||||||
|
const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
|
||||||
|
|
||||||
|
if (deleteSessionData && body.message.trim() !== '삭제') {
|
||||||
|
try {
|
||||||
|
const { label } = JSON.parse(deleteSessionData);
|
||||||
|
await env.SESSION_KV.delete(deleteSessionKey);
|
||||||
|
|
||||||
|
// Don't show cancellation message if it's a command (let command handler process it)
|
||||||
|
if (!body.message.startsWith('/')) {
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Chat API - 삭제 세션 취소 오류', toError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 신청 확인 처리 (텍스트 기반) - Queue 기반
|
||||||
|
if (body.message.trim() === '신청') {
|
||||||
|
const orderSessionKey = `server_order_confirm:${telegramUserId}`;
|
||||||
|
logger.info('신청 세션 확인', { orderSessionKey, telegramUserId });
|
||||||
|
const orderSessionData = await env.SESSION_KV.get(orderSessionKey);
|
||||||
|
logger.info('신청 세션 데이터', { found: !!orderSessionData, data: orderSessionData?.slice(0, 100) });
|
||||||
|
|
||||||
|
if (orderSessionData) {
|
||||||
|
try {
|
||||||
|
const orderData = JSON.parse(orderSessionData);
|
||||||
|
|
||||||
|
// 1. 서버 세션에서 가격 정보 가져오기
|
||||||
|
const { getServerSession, deleteServerSession } = await import('../../server-agent');
|
||||||
|
const session = await getServerSession(env.DB, telegramUserId);
|
||||||
|
|
||||||
|
if (!session || !session.lastRecommendation) {
|
||||||
|
await env.SESSION_KV.delete(orderSessionKey);
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.',
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = session.lastRecommendation.recommendations[orderData.index];
|
||||||
|
if (!selected) {
|
||||||
|
await env.SESSION_KV.delete(orderSessionKey);
|
||||||
|
await deleteServerSession(env.DB, telegramUserId);
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: '❌ 선택한 서버를 찾을 수 없습니다.',
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = selected.price?.monthly_krw || 0;
|
||||||
|
|
||||||
|
// 2. 잔액 확인
|
||||||
|
const deposit = await env.DB.prepare(
|
||||||
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(userId).first<{ balance: number }>();
|
||||||
|
|
||||||
|
if (!deposit || deposit.balance < price) {
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response:
|
||||||
|
`❌ 잔액이 부족합니다.\n\n` +
|
||||||
|
`• 서버 가격: ${price.toLocaleString()}원/월\n` +
|
||||||
|
`• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` +
|
||||||
|
`• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` +
|
||||||
|
`잔액을 충전 후 다시 시도해주세요.`,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Queue 확인
|
||||||
|
if (!env.SERVER_PROVISION_QUEUE) {
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.',
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 주문 생성 및 Queue 전송
|
||||||
|
const { createServerOrder, sendProvisionMessage } = await import('../../server-provision');
|
||||||
|
|
||||||
|
const orderId = await createServerOrder(
|
||||||
|
env.DB,
|
||||||
|
userId,
|
||||||
|
telegramUserId,
|
||||||
|
selected.pricing_id,
|
||||||
|
selected.region.code,
|
||||||
|
'anvil',
|
||||||
|
price,
|
||||||
|
`${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId);
|
||||||
|
|
||||||
|
// 5. 세션 정리
|
||||||
|
await env.SESSION_KV.delete(orderSessionKey);
|
||||||
|
await deleteServerSession(env.DB, telegramUserId);
|
||||||
|
|
||||||
|
// 6. 즉시 응답
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response:
|
||||||
|
`📋 <b>서버 주문 접수 완료!</b> (주문 #${orderId})\n\n` +
|
||||||
|
`• 서버: ${selected.plan_name}\n` +
|
||||||
|
`• 리전: ${selected.region.name} (${selected.region.code})\n` +
|
||||||
|
`• 가격: ${price.toLocaleString()}원/월\n\n` +
|
||||||
|
`⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` +
|
||||||
|
`완료되면 메시지로 알려드릴게요.`,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Chat API - 서버 신청 처리 오류', toError(error));
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: '🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.',
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 신청 취소 처리 (다른 메시지 입력 시)
|
||||||
|
const orderSessionKey = `server_order_confirm:${telegramUserId}`;
|
||||||
|
const orderSessionData = await env.SESSION_KV.get(orderSessionKey);
|
||||||
|
|
||||||
|
if (orderSessionData && body.message.trim() !== '신청') {
|
||||||
|
try {
|
||||||
|
const { plan } = JSON.parse(orderSessionData);
|
||||||
|
await env.SESSION_KV.delete(orderSessionKey);
|
||||||
|
|
||||||
|
// Don't show cancellation message if it's a command
|
||||||
|
if (!body.message.startsWith('/')) {
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: `⏹️ 서버 신청이 취소되었습니다.\n\n신청하려던 서버: ${plan}`,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Chat API - 신청 세션 취소 오류', toError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 명령어 처리
|
||||||
|
if (body.message.startsWith('/')) {
|
||||||
|
const [command, ...argParts] = body.message.split(' ');
|
||||||
|
const args = argParts.join(' ');
|
||||||
|
responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
||||||
|
} else {
|
||||||
|
// 1. 사용자 메시지 버퍼에 추가
|
||||||
|
await addToBuffer(env.DB, userId, chatIdStr, 'user', body.message);
|
||||||
|
|
||||||
|
// 2. AI 응답 생성
|
||||||
|
responseText = await generateAIResponse(env, userId, chatIdStr, body.message, telegramUserId);
|
||||||
|
|
||||||
|
// __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비)
|
||||||
|
if (responseText.includes('__DIRECT__')) {
|
||||||
|
const directIndex = responseText.indexOf('__DIRECT__');
|
||||||
|
responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 봇 응답 버퍼에 추가
|
||||||
|
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
||||||
|
|
||||||
|
// 4. 임계값 도달시 프로필 업데이트
|
||||||
|
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
|
||||||
|
if (summarized) {
|
||||||
|
responseText += '\n\n👤 프로필이 업데이트되었습니다.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
logger.info('Chat API request processed', {
|
||||||
|
user_id: telegramUserId,
|
||||||
|
username,
|
||||||
|
message_length: body.message.length,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
response: responseText,
|
||||||
|
processing_time_ms: processingTimeMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const processingTimeMs = Date.now() - startTime;
|
||||||
|
logger.error('Chat API error', toError(error), { processing_time_ms: processingTimeMs });
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatRouter = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
// POST /test - 테스트 API (개발 환경 전용)
|
||||||
|
chatRouter.post('/test', async (c) => {
|
||||||
|
return await handleTestApi(c.req.raw, c.env);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /chat - 인증된 채팅 API (프로덕션)
|
||||||
|
chatRouter.post('/chat', async (c) => {
|
||||||
|
return await handleChatApi(c.req.raw, c.env);
|
||||||
|
});
|
||||||
132
src/routes/api/contact.ts
Normal file
132
src/routes/api/contact.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { Env } from '../../types';
|
||||||
|
import { sendMessage } from '../../telegram';
|
||||||
|
import { createLogger } from '../../utils/logger';
|
||||||
|
import { toError } from '../../utils/error';
|
||||||
|
|
||||||
|
const logger = createLogger('api-contact');
|
||||||
|
|
||||||
|
// Zod schema for contact form validation
|
||||||
|
const ContactFormBodySchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
message: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 헤더 생성
|
||||||
|
*/
|
||||||
|
function getCorsHeaders(env: Env): Record<string, string> {
|
||||||
|
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /contact - 문의 폼 API (웹사이트용)
|
||||||
|
*
|
||||||
|
* @param request - HTTP Request with body
|
||||||
|
* @param env - Environment bindings
|
||||||
|
* @returns JSON response with success status
|
||||||
|
*/
|
||||||
|
async function handleContactForm(request: Request, env: Env): Promise<Response> {
|
||||||
|
// CORS 헤더 생성
|
||||||
|
const corsHeaders = getCorsHeaders(env);
|
||||||
|
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
|
|
||||||
|
// Origin 헤더 검증 (curl 우회 방지)
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
|
||||||
|
if (!origin || origin !== allowedOrigin) {
|
||||||
|
logger.warn('Contact API - 허용되지 않은 Origin', { origin });
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Forbidden' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// JSON 파싱 (별도 에러 핸들링)
|
||||||
|
let jsonData: unknown;
|
||||||
|
try {
|
||||||
|
jsonData = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Invalid JSON format' },
|
||||||
|
{ status: 400, headers: corsHeaders }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = ContactFormBodySchema.safeParse(jsonData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
logger.warn('Contact form - Invalid request body', { errors: parseResult.error.issues });
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: '올바르지 않은 요청 형식입니다.',
|
||||||
|
details: parseResult.error.issues
|
||||||
|
},
|
||||||
|
{ status: 400, headers: corsHeaders }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parseResult.data;
|
||||||
|
|
||||||
|
// 메시지 길이 제한
|
||||||
|
if (body.message.length > 2000) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '메시지는 2000자 이내로 작성해주세요.' },
|
||||||
|
{ status: 400, headers: corsHeaders }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자에게 텔레그램 알림
|
||||||
|
const adminId = env.DEPOSIT_ADMIN_ID || env.DOMAIN_OWNER_ID;
|
||||||
|
if (env.BOT_TOKEN && adminId) {
|
||||||
|
const timestamp = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
parseInt(adminId),
|
||||||
|
`📬 <b>웹사이트 문의</b>\n\n` +
|
||||||
|
`📧 이메일: <code>${body.email}</code>\n` +
|
||||||
|
`🕐 시간: ${timestamp}\n\n` +
|
||||||
|
`💬 내용:\n${body.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('문의 수신', { email: body.email, hasName: !!body.name });
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, message: '문의가 성공적으로 전송되었습니다.' },
|
||||||
|
{ headers: corsHeaders }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Contact form error', toError(error));
|
||||||
|
return Response.json(
|
||||||
|
{ error: '문의 전송 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500, headers: corsHeaders }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contactRouter = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
// CORS middleware for /contact endpoint
|
||||||
|
contactRouter.use('/*', cors({
|
||||||
|
origin: (origin, c) => {
|
||||||
|
const allowedOrigin = c.env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
|
return origin === allowedOrigin ? origin : null;
|
||||||
|
},
|
||||||
|
allowMethods: ['POST', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST / - 문의 폼 API (웹사이트용)
|
||||||
|
contactRouter.post('/', async (c) => {
|
||||||
|
return await handleContactForm(c.req.raw, c.env);
|
||||||
|
});
|
||||||
215
src/routes/api/deposit.ts
Normal file
215
src/routes/api/deposit.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { createMiddleware } from 'hono/factory';
|
||||||
|
import { Env } from '../../types';
|
||||||
|
import { timingSafeEqual } from '../../security';
|
||||||
|
import { createLogger } from '../../utils/logger';
|
||||||
|
import { toError } from '../../utils/error';
|
||||||
|
|
||||||
|
const logger = createLogger('api-deposit');
|
||||||
|
|
||||||
|
// Zod schema for deposit deduct validation
|
||||||
|
const DepositDeductBodySchema = z.object({
|
||||||
|
telegram_id: z.string(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
reason: z.string(),
|
||||||
|
reference_id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key 인증 미들웨어 (Timing-safe comparison으로 타이밍 공격 방지)
|
||||||
|
* X-API-Key 헤더로 DEPOSIT_API_SECRET 검증
|
||||||
|
*/
|
||||||
|
const apiKeyAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
|
||||||
|
const apiSecret = c.env.DEPOSIT_API_SECRET;
|
||||||
|
const authHeader = c.req.header('X-API-Key');
|
||||||
|
|
||||||
|
if (!apiSecret || !timingSafeEqual(authHeader, apiSecret)) {
|
||||||
|
logger.warn('API Key 인증 실패', { hasApiKey: !!authHeader });
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /balance - 잔액 조회 (namecheap-api 전용)
|
||||||
|
*
|
||||||
|
* @param env - Environment bindings
|
||||||
|
* @param url - Parsed URL
|
||||||
|
* @returns JSON response with balance
|
||||||
|
*/
|
||||||
|
async function handleDepositBalance(env: Env, url: URL): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const telegramId = url.searchParams.get('telegram_id');
|
||||||
|
if (!telegramId) {
|
||||||
|
return Response.json({ error: 'telegram_id required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 조회
|
||||||
|
const user = await env.DB.prepare(
|
||||||
|
'SELECT id FROM users WHERE telegram_id = ?'
|
||||||
|
).bind(telegramId).first<{ id: number }>();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잔액 조회
|
||||||
|
const deposit = await env.DB.prepare(
|
||||||
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(user.id).first<{ balance: number }>();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
telegram_id: telegramId,
|
||||||
|
balance: deposit?.balance || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Deposit balance error', toError(error));
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /deduct - 잔액 차감 (namecheap-api 전용)
|
||||||
|
*
|
||||||
|
* @param request - HTTP Request with body
|
||||||
|
* @param env - Environment bindings
|
||||||
|
* @returns JSON response with transaction result
|
||||||
|
*/
|
||||||
|
async function handleDepositDeduct(request: Request, env: Env): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// JSON 파싱 (별도 에러 핸들링)
|
||||||
|
let jsonData: unknown;
|
||||||
|
try {
|
||||||
|
jsonData = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = DepositDeductBodySchema.safeParse(jsonData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
logger.warn('Deposit deduct - Invalid request body', { errors: parseResult.error.issues });
|
||||||
|
return Response.json({
|
||||||
|
error: 'Invalid request body',
|
||||||
|
details: parseResult.error.issues
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parseResult.data;
|
||||||
|
|
||||||
|
// 사용자 조회
|
||||||
|
const user = await env.DB.prepare(
|
||||||
|
'SELECT id FROM users WHERE telegram_id = ?'
|
||||||
|
).bind(body.telegram_id).first<{ id: number }>();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 잔액과 version 확인 (Optimistic Locking)
|
||||||
|
const current = await env.DB.prepare(
|
||||||
|
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(user.id).first<{ balance: number; version: number }>();
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return Response.json({ error: 'User deposit account not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.balance < body.amount) {
|
||||||
|
return Response.json({
|
||||||
|
error: 'Insufficient balance',
|
||||||
|
current_balance: current.balance,
|
||||||
|
required: body.amount,
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic Locking: version 조건으로 잔액 차감
|
||||||
|
const balanceUpdate = await env.DB.prepare(
|
||||||
|
'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
|
||||||
|
).bind(body.amount, user.id, current.version).run();
|
||||||
|
|
||||||
|
if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
|
||||||
|
logger.warn('Optimistic locking conflict (외부 API 잔액 차감)', {
|
||||||
|
userId: user.id,
|
||||||
|
telegram_id: body.telegram_id,
|
||||||
|
amount: body.amount,
|
||||||
|
expectedVersion: current.version,
|
||||||
|
context: 'api_deposit_deduct'
|
||||||
|
});
|
||||||
|
return Response.json({
|
||||||
|
error: 'Concurrent modification detected',
|
||||||
|
message: '동시 요청 감지 - 다시 시도해주세요'
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 거래 기록 INSERT
|
||||||
|
const transactionInsert = await env.DB.prepare(
|
||||||
|
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
|
||||||
|
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
|
||||||
|
).bind(user.id, body.amount, body.reason).run();
|
||||||
|
|
||||||
|
if (!transactionInsert.success) {
|
||||||
|
// 잔액 복구 시도 (rollback)
|
||||||
|
try {
|
||||||
|
await env.DB.prepare(
|
||||||
|
'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
||||||
|
).bind(body.amount, user.id).run();
|
||||||
|
|
||||||
|
logger.error('거래 기록 INSERT 실패 - 잔액 복구 완료', undefined, {
|
||||||
|
userId: user.id,
|
||||||
|
telegram_id: body.telegram_id,
|
||||||
|
amount: body.amount,
|
||||||
|
reason: body.reason,
|
||||||
|
context: 'api_deposit_deduct_rollback'
|
||||||
|
});
|
||||||
|
} catch (rollbackError) {
|
||||||
|
logger.error('잔액 복구 실패 - 수동 확인 필요', toError(rollbackError), {
|
||||||
|
userId: user.id,
|
||||||
|
telegram_id: body.telegram_id,
|
||||||
|
amount: body.amount,
|
||||||
|
context: 'api_deposit_deduct_rollback_failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
error: 'Transaction processing failed',
|
||||||
|
message: '거래 처리 실패 - 관리자에게 문의하세요'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBalance = current.balance - body.amount;
|
||||||
|
|
||||||
|
logger.info('Deposit deducted', {
|
||||||
|
telegram_id: body.telegram_id,
|
||||||
|
amount: body.amount,
|
||||||
|
reason: body.reason,
|
||||||
|
new_balance: newBalance
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
telegram_id: body.telegram_id,
|
||||||
|
deducted: body.amount,
|
||||||
|
previous_balance: current.balance,
|
||||||
|
new_balance: newBalance,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Deposit deduct error', toError(error));
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const depositRouter = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
// GET /balance - 잔액 조회 (namecheap-api 전용, API Key 필요)
|
||||||
|
depositRouter.get('/balance', apiKeyAuth, async (c) => {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
return await handleDepositBalance(c.env, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /deduct - 잔액 차감 (namecheap-api 전용, API Key 필요)
|
||||||
|
depositRouter.post('/deduct', apiKeyAuth, async (c) => {
|
||||||
|
return await handleDepositDeduct(c.req.raw, c.env);
|
||||||
|
});
|
||||||
77
src/routes/api/metrics.ts
Normal file
77
src/routes/api/metrics.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { Env } from '../../types';
|
||||||
|
import { openaiCircuitBreaker } from '../../openai-service';
|
||||||
|
import { timingSafeEqual } from '../../security';
|
||||||
|
import { createLogger } from '../../utils/logger';
|
||||||
|
import { toError } from '../../utils/error';
|
||||||
|
|
||||||
|
const logger = createLogger('api-metrics');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /metrics - Circuit Breaker 상태 조회 (관리자 전용)
|
||||||
|
*
|
||||||
|
* @param request - HTTP Request
|
||||||
|
* @param env - Environment bindings
|
||||||
|
* @returns JSON response with metrics
|
||||||
|
*/
|
||||||
|
async function handleMetrics(request: Request, env: Env): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// WEBHOOK_SECRET 인증 (Timing-safe comparison으로 타이밍 공격 방지)
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`;
|
||||||
|
if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) {
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circuit Breaker 상태 수집
|
||||||
|
const openaiStats = openaiCircuitBreaker.getStats();
|
||||||
|
|
||||||
|
// 메트릭 응답 생성
|
||||||
|
const metrics = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
circuitBreakers: {
|
||||||
|
openai: {
|
||||||
|
state: openaiStats.state,
|
||||||
|
failures: openaiStats.failures,
|
||||||
|
lastFailureTime: openaiStats.lastFailureTime?.toISOString(),
|
||||||
|
stats: openaiStats.stats,
|
||||||
|
config: openaiStats.config,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 추후 확장 가능: API 호출 통계, 캐시 hit rate 등
|
||||||
|
metrics: {
|
||||||
|
api_calls: {
|
||||||
|
// 추후 구현: 실제 API 호출 통계
|
||||||
|
openai: { count: openaiStats.stats.totalRequests, avg_duration: 0 },
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
// 추후 구현: 에러 통계
|
||||||
|
retry_exhausted: 0,
|
||||||
|
circuit_breaker_open: openaiStats.state === 'OPEN' ? 1 : 0,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
// 추후 구현: 캐시 hit rate
|
||||||
|
hit_rate: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Metrics retrieved', {
|
||||||
|
state: openaiStats.state,
|
||||||
|
failures: openaiStats.failures,
|
||||||
|
requests: openaiStats.stats.totalRequests
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Metrics API error', toError(error));
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metricsRouter = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
// GET / - Circuit Breaker 상태 조회 (관리자 전용)
|
||||||
|
metricsRouter.get('/', async (c) => {
|
||||||
|
return await handleMetrics(c.req.raw, c.env);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Tool Registry - All tools exported from here
|
// Tool Registry - All tools exported from here
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createLogger } from '../utils/logger';
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { detectToolCategories } from '../utils/patterns';
|
||||||
|
|
||||||
const logger = createLogger('tools');
|
const logger = createLogger('tools');
|
||||||
|
|
||||||
@@ -113,26 +114,13 @@ export const TOOL_CATEGORIES: Record<string, string[]> = {
|
|||||||
utility: [getCurrentTimeTool.function.name, calculateTool.function.name],
|
utility: [getCurrentTimeTool.function.name, calculateTool.function.name],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Category detection patterns
|
|
||||||
export const CATEGORY_PATTERNS: Record<string, RegExp> = {
|
|
||||||
domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i,
|
|
||||||
deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i,
|
|
||||||
server: /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i,
|
|
||||||
troubleshoot: /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i,
|
|
||||||
weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i,
|
|
||||||
search: /검색|찾아|뭐야|뉴스|최신/i,
|
|
||||||
reddit: /레딧|reddit|서브레딧|subreddit/i,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Message-based tool selection
|
// Message-based tool selection
|
||||||
export function selectToolsForMessage(message: string): typeof tools {
|
export function selectToolsForMessage(message: string): typeof tools {
|
||||||
const selectedCategories = new Set<string>(['utility']); // 항상 포함
|
const selectedCategories = new Set<string>(['utility']); // 항상 포함
|
||||||
|
|
||||||
for (const [category, pattern] of Object.entries(CATEGORY_PATTERNS)) {
|
// Use centralized pattern detection
|
||||||
if (pattern.test(message)) {
|
const detectedCategories = detectToolCategories(message);
|
||||||
selectedCategories.add(category);
|
detectedCategories.forEach(cat => selectedCategories.add(cat));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 패턴 매칭 없으면 유틸리티 도구만 사용 (토큰 절약)
|
// 패턴 매칭 없으면 유틸리티 도구만 사용 (토큰 절약)
|
||||||
if (selectedCategories.size === 1) { // utility만 있으면
|
if (selectedCategories.size === 1) { // utility만 있으면
|
||||||
|
|||||||
195
src/utils/patterns.ts
Normal file
195
src/utils/patterns.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Centralized pattern detection for keyword matching
|
||||||
|
*
|
||||||
|
* Purpose: Unified keyword matching patterns used across multiple services:
|
||||||
|
* - Tool category detection (tools/index.ts)
|
||||||
|
* - Memory category detection (openai-service.ts)
|
||||||
|
* - Region/tech stack detection (server-agent.ts)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tool Category Patterns
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DOMAIN_PATTERNS = /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i;
|
||||||
|
export const DEPOSIT_PATTERNS = /입금|충전|잔액|계좌|예치금|송금|돈/i;
|
||||||
|
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i;
|
||||||
|
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i;
|
||||||
|
export const WEATHER_PATTERNS = /날씨|기온|비|눈|맑|흐림|더워|추워/i;
|
||||||
|
export const SEARCH_PATTERNS = /검색|찾아|뭐야|뉴스|최신/i;
|
||||||
|
export const REDDIT_PATTERNS = /레딧|reddit|서브레딧|subreddit/i;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Memory Category Patterns
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Company/workplace patterns
|
||||||
|
export const COMPANY_PATTERNS = /(?:에서|에)\s*(?:일해|일하고|근무|다녀)/;
|
||||||
|
|
||||||
|
// Tech/learning patterns
|
||||||
|
export const TECH_LEARNING_PATTERNS = /(?:공부|개발|작업|배우)/;
|
||||||
|
|
||||||
|
// Role patterns
|
||||||
|
export const ROLE_PATTERNS = /(?:개발자|엔지니어|디자이너|기획자)/;
|
||||||
|
|
||||||
|
// Location patterns
|
||||||
|
export const LOCATION_PATTERNS = /(?:에서|에)\s*(?:살아|거주|있어)/;
|
||||||
|
|
||||||
|
// Server/infrastructure patterns (for memory)
|
||||||
|
export const SERVER_INFRA_PATTERNS = /(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24|서버\s*\d|트래픽|DAU|MAU|동시접속|쿠버네티스|k8s|도커|docker|컨테이너)/i;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Region Patterns (Korean/English/Japanese)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const REGION_PATTERNS = {
|
||||||
|
korea: /한국|서울|korea|seoul|kr\b/i,
|
||||||
|
japan: /일본|도쿄|tokyo|japan|jp\b/i,
|
||||||
|
osaka: /오사카|osaka/i,
|
||||||
|
singapore: /싱가포르|singapore|sg\b/i,
|
||||||
|
us: /미국|us|america|달라스|프리몬트/i,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tech Stack Patterns
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const TECH_PATTERNS = {
|
||||||
|
// Databases
|
||||||
|
postgresql: /postgresql|postgres|postgis/i,
|
||||||
|
mysql: /mysql|mariadb/i,
|
||||||
|
mongodb: /mongodb|mongo/i,
|
||||||
|
|
||||||
|
// Cache/Messaging
|
||||||
|
redis: /redis/i,
|
||||||
|
memcached: /memcached/i,
|
||||||
|
messaging: /kafka|rabbitmq/i,
|
||||||
|
|
||||||
|
// Runtimes
|
||||||
|
nodejs: /node\.?js|nodejs|express/i,
|
||||||
|
python: /python|django|flask|fastapi/i,
|
||||||
|
java: /java|spring/i,
|
||||||
|
go: /golang|go\s/i,
|
||||||
|
|
||||||
|
// Platforms
|
||||||
|
wordpress: /wordpress/i,
|
||||||
|
php: /laravel|php/i,
|
||||||
|
|
||||||
|
// Service Types
|
||||||
|
saas: /saas|b2b|enterprise/i,
|
||||||
|
ecommerce: /ecommerce|쇼핑몰|이커머스/i,
|
||||||
|
game: /게임|game|minecraft|팰월드|palworld/i,
|
||||||
|
streaming: /streaming|스트리밍|video/i,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pattern Matching Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text matches a given pattern
|
||||||
|
*/
|
||||||
|
export function matchesPattern(text: string, pattern: RegExp): boolean {
|
||||||
|
return pattern.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect tool categories from message text
|
||||||
|
* @returns Array of category names that match
|
||||||
|
*/
|
||||||
|
export function detectToolCategories(text: string): string[] {
|
||||||
|
const categories: string[] = [];
|
||||||
|
|
||||||
|
if (DOMAIN_PATTERNS.test(text)) categories.push('domain');
|
||||||
|
if (DEPOSIT_PATTERNS.test(text)) categories.push('deposit');
|
||||||
|
if (SERVER_PATTERNS.test(text)) categories.push('server');
|
||||||
|
if (TROUBLESHOOT_PATTERNS.test(text)) categories.push('troubleshoot');
|
||||||
|
if (WEATHER_PATTERNS.test(text)) categories.push('weather');
|
||||||
|
if (SEARCH_PATTERNS.test(text)) categories.push('search');
|
||||||
|
if (REDDIT_PATTERNS.test(text)) categories.push('reddit');
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect memory category from message content
|
||||||
|
* @returns Category name or null if no match
|
||||||
|
*/
|
||||||
|
export type MemoryCategory = 'company' | 'tech' | 'role' | 'location' | 'server' | null;
|
||||||
|
|
||||||
|
export function detectMemoryCategory(content: string): MemoryCategory {
|
||||||
|
// Company/workplace
|
||||||
|
if (COMPANY_PATTERNS.test(content)) {
|
||||||
|
return 'company';
|
||||||
|
}
|
||||||
|
// Tech/learning
|
||||||
|
if (TECH_LEARNING_PATTERNS.test(content)) {
|
||||||
|
return 'tech';
|
||||||
|
}
|
||||||
|
// Role
|
||||||
|
if (ROLE_PATTERNS.test(content)) {
|
||||||
|
return 'role';
|
||||||
|
}
|
||||||
|
// Location
|
||||||
|
if (LOCATION_PATTERNS.test(content)) {
|
||||||
|
return 'location';
|
||||||
|
}
|
||||||
|
// Server/infrastructure
|
||||||
|
if (SERVER_INFRA_PATTERNS.test(content)) {
|
||||||
|
return 'server';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect region preference from text
|
||||||
|
* @returns Array of region codes or undefined
|
||||||
|
*/
|
||||||
|
export function detectRegion(text: string): string[] | undefined {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const regions: string[] = [];
|
||||||
|
|
||||||
|
if (REGION_PATTERNS.korea.test(lower)) regions.push('seoul');
|
||||||
|
if (REGION_PATTERNS.japan.test(lower)) regions.push('tokyo');
|
||||||
|
if (REGION_PATTERNS.osaka.test(lower)) regions.push('osaka');
|
||||||
|
if (REGION_PATTERNS.singapore.test(lower)) regions.push('singapore');
|
||||||
|
|
||||||
|
return regions.length > 0 ? regions : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect tech stack from text
|
||||||
|
* @returns Array of tech stack names
|
||||||
|
*/
|
||||||
|
export function detectTechStack(text: string): string[] {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const stack: string[] = [];
|
||||||
|
|
||||||
|
// Databases
|
||||||
|
if (TECH_PATTERNS.postgresql.test(lower)) stack.push('postgresql');
|
||||||
|
if (TECH_PATTERNS.mysql.test(lower)) stack.push('mysql');
|
||||||
|
if (TECH_PATTERNS.mongodb.test(lower)) stack.push('mongodb');
|
||||||
|
|
||||||
|
// Cache/Messaging
|
||||||
|
if (TECH_PATTERNS.redis.test(lower)) stack.push('redis');
|
||||||
|
if (TECH_PATTERNS.memcached.test(lower)) stack.push('memcached');
|
||||||
|
if (TECH_PATTERNS.messaging.test(lower)) stack.push('messaging');
|
||||||
|
|
||||||
|
// Runtimes
|
||||||
|
if (TECH_PATTERNS.nodejs.test(lower)) stack.push('nodejs');
|
||||||
|
if (TECH_PATTERNS.python.test(lower)) stack.push('python');
|
||||||
|
if (TECH_PATTERNS.java.test(lower)) stack.push('java');
|
||||||
|
if (TECH_PATTERNS.go.test(lower)) stack.push('go');
|
||||||
|
|
||||||
|
// Platforms
|
||||||
|
if (TECH_PATTERNS.wordpress.test(lower)) stack.push('wordpress');
|
||||||
|
if (TECH_PATTERNS.php.test(lower)) stack.push('php');
|
||||||
|
|
||||||
|
// Service Types
|
||||||
|
if (TECH_PATTERNS.saas.test(lower)) stack.push('saas');
|
||||||
|
if (TECH_PATTERNS.ecommerce.test(lower)) stack.push('ecommerce');
|
||||||
|
if (TECH_PATTERNS.game.test(lower)) stack.push('game');
|
||||||
|
if (TECH_PATTERNS.streaming.test(lower)) stack.push('streaming');
|
||||||
|
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user