보안 개선: - API 키 하드코딩 제거 (NAMECHEAP_API_KEY_INTERNAL) - CORS 정책: * → hosting.anvil.it.com 제한 - /health 엔드포인트 DB 정보 노출 방지 - Rate Limiting 인메모리 Map → Cloudflare KV 전환 - 분산 환경 일관성 보장 - 재시작 후에도 유지 - 자동 만료 (TTL) 문서: - CLAUDE.md Security 섹션 추가 - KV Namespace 설정 가이드 추가 - 배포/마이그레이션 가이드 추가 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
921 lines
30 KiB
TypeScript
921 lines
30 KiB
TypeScript
import { Env, TelegramUpdate, EmailMessage, BankNotification } from './types';
|
|
import { validateWebhookRequest, checkRateLimit } from './security';
|
|
import { sendMessage, sendMessageWithKeyboard, setWebhook, getWebhookInfo, sendChatAction, answerCallbackQuery, editMessageText } from './telegram';
|
|
import { executeDomainRegister } from './domain-register';
|
|
import {
|
|
addToBuffer,
|
|
processAndSummarize,
|
|
generateAIResponse,
|
|
} from './summary-service';
|
|
import { handleCommand } from './commands';
|
|
|
|
// 사용자 조회/생성
|
|
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;
|
|
}
|
|
|
|
// 메시지 처리
|
|
async function handleMessage(
|
|
env: Env,
|
|
update: TelegramUpdate
|
|
): Promise<void> {
|
|
if (!update.message?.text) return;
|
|
|
|
const { message } = update;
|
|
const chatId = message.chat.id;
|
|
const chatIdStr = chatId.toString();
|
|
const text = message.text;
|
|
const telegramUserId = message.from.id.toString();
|
|
|
|
// Rate Limiting 체크 (KV 기반)
|
|
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
'⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 사용자 처리 (오류 시 사용자에게 알림)
|
|
let userId: number;
|
|
try {
|
|
userId = await getOrCreateUser(
|
|
env.DB,
|
|
telegramUserId,
|
|
message.from.first_name,
|
|
message.from.username
|
|
);
|
|
} catch (dbError) {
|
|
console.error('[handleMessage] 사용자 DB 오류:', dbError);
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
'⚠️ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
let responseText: string;
|
|
|
|
try {
|
|
// 명령어 처리
|
|
if (text.startsWith('/')) {
|
|
const [command, ...argParts] = text.split(' ');
|
|
const args = argParts.join(' ');
|
|
responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
|
|
|
// /start 명령어는 미니앱 버튼과 함께 전송
|
|
if (command === '/start') {
|
|
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [
|
|
[{ text: '🌐 서비스 보기', web_app: { url: 'https://hosting.anvil.it.com' } }],
|
|
[{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }],
|
|
]);
|
|
return;
|
|
}
|
|
} else {
|
|
// 타이핑 표시
|
|
await sendChatAction(env.BOT_TOKEN, chatId, 'typing');
|
|
|
|
// 1. 사용자 메시지 버퍼에 추가
|
|
await addToBuffer(env.DB, userId, chatIdStr, 'user', text);
|
|
|
|
// 2. AI 응답 생성
|
|
responseText = await generateAIResponse(env, userId, chatIdStr, text, telegramUserId);
|
|
|
|
// 3. 봇 응답 버퍼에 추가
|
|
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
|
|
|
|
// 4. 임계값 도달시 프로필 업데이트
|
|
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
|
|
|
|
if (summarized) {
|
|
responseText += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[handleMessage] 처리 오류:', error);
|
|
responseText = '⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
|
}
|
|
|
|
// 버튼 데이터 파싱
|
|
const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/);
|
|
if (keyboardMatch) {
|
|
const cleanText = responseText.replace(/__KEYBOARD__.+?__END__\n?/, '');
|
|
try {
|
|
const keyboardData = JSON.parse(keyboardMatch[1]);
|
|
|
|
if (keyboardData.type === 'domain_register') {
|
|
// 도메인 등록 확인 버튼
|
|
const callbackData = `domain_reg:${keyboardData.domain}:${keyboardData.price}`;
|
|
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, cleanText, [
|
|
[
|
|
{ text: '✅ 등록하기', callback_data: callbackData },
|
|
{ text: '❌ 취소', callback_data: 'domain_cancel' }
|
|
]
|
|
]);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error('[Keyboard] 파싱 오류:', e);
|
|
}
|
|
}
|
|
|
|
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
|
}
|
|
|
|
// Callback Query 처리 (인라인 버튼 클릭)
|
|
async function handleCallbackQuery(
|
|
env: Env,
|
|
callbackQuery: TelegramUpdate['callback_query']
|
|
): Promise<void> {
|
|
if (!callbackQuery) return;
|
|
|
|
const { id: queryId, from, message, data } = callbackQuery;
|
|
if (!data || !message) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
|
|
return;
|
|
}
|
|
|
|
const chatId = message.chat.id;
|
|
const messageId = message.message_id;
|
|
const telegramUserId = from.id.toString();
|
|
|
|
// 사용자 조회
|
|
const user = await env.DB.prepare(
|
|
'SELECT id FROM users WHERE telegram_id = ?'
|
|
).bind(telegramUserId).first<{ id: number }>();
|
|
|
|
if (!user) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
|
|
return;
|
|
}
|
|
|
|
// 도메인 등록 처리
|
|
if (data.startsWith('domain_reg:')) {
|
|
const parts = data.split(':');
|
|
if (parts.length !== 3) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
|
return;
|
|
}
|
|
|
|
const domain = parts[1];
|
|
const price = parseInt(parts[2]);
|
|
|
|
// 처리 중 표시
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
`⏳ <b>${domain}</b> 등록 처리 중...`
|
|
);
|
|
|
|
// 도메인 등록 실행
|
|
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
|
|
|
|
if (result.success) {
|
|
const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : '';
|
|
const nsInfo = result.nameservers && result.nameservers.length > 0
|
|
? `\n\n🌐 <b>현재 네임서버:</b>\n${result.nameservers.map(ns => `• <code>${ns}</code>`).join('\n')}`
|
|
: '';
|
|
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
`✅ <b>도메인 등록 완료!</b>
|
|
|
|
• 도메인: <code>${result.domain}</code>
|
|
• 결제 금액: ${result.price?.toLocaleString()}원
|
|
• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo}
|
|
|
|
🎉 축하합니다! 도메인이 성공적으로 등록되었습니다.
|
|
네임서버 변경이 필요하면 말씀해주세요.`
|
|
);
|
|
} else {
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
`❌ <b>등록 실패</b>
|
|
|
|
${result.error}
|
|
|
|
다시 시도하시려면 도메인 등록을 요청해주세요.`
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 도메인 등록 취소
|
|
if (data === 'domain_cancel') {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'❌ 도메인 등록이 취소되었습니다.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
|
}
|
|
|
|
export default {
|
|
// HTTP 요청 핸들러
|
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
|
|
// Webhook 설정 엔드포인트
|
|
if (url.pathname === '/setup-webhook') {
|
|
if (!env.BOT_TOKEN) {
|
|
return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 });
|
|
}
|
|
if (!env.WEBHOOK_SECRET) {
|
|
return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 });
|
|
}
|
|
|
|
const webhookUrl = `${url.origin}/webhook`;
|
|
const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET);
|
|
return Response.json(result);
|
|
}
|
|
|
|
// Webhook 정보 조회
|
|
if (url.pathname === '/webhook-info') {
|
|
if (!env.BOT_TOKEN) {
|
|
return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 });
|
|
}
|
|
const result = await getWebhookInfo(env.BOT_TOKEN);
|
|
return Response.json(result);
|
|
}
|
|
|
|
// 헬스 체크 (공개 - 최소 정보만)
|
|
if (url.pathname === '/health') {
|
|
return Response.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
// Deposit API - 잔액 조회 (namecheap-api 전용)
|
|
if (url.pathname === '/api/deposit/balance' && request.method === 'GET') {
|
|
try {
|
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
|
const authHeader = request.headers.get('X-API-Key');
|
|
|
|
if (!apiSecret || authHeader !== apiSecret) {
|
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
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) {
|
|
console.error('[API] Deposit balance error:', error);
|
|
return Response.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Deposit API - 잔액 차감 (namecheap-api 전용)
|
|
if (url.pathname === '/api/deposit/deduct' && request.method === 'POST') {
|
|
try {
|
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
|
const authHeader = request.headers.get('X-API-Key');
|
|
|
|
if (!apiSecret || authHeader !== apiSecret) {
|
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const body = await request.json() as {
|
|
telegram_id: string;
|
|
amount: number;
|
|
reason: string;
|
|
reference_id?: string;
|
|
};
|
|
|
|
if (!body.telegram_id || !body.amount || !body.reason) {
|
|
return Response.json({ error: 'telegram_id, amount, reason required' }, { status: 400 });
|
|
}
|
|
|
|
if (body.amount <= 0) {
|
|
return Response.json({ error: 'Amount must be positive' }, { status: 400 });
|
|
}
|
|
|
|
// 사용자 조회
|
|
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 });
|
|
}
|
|
|
|
// 현재 잔액 확인
|
|
const deposit = await env.DB.prepare(
|
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
|
).bind(user.id).first<{ balance: number }>();
|
|
|
|
const currentBalance = deposit?.balance || 0;
|
|
if (currentBalance < body.amount) {
|
|
return Response.json({
|
|
error: 'Insufficient balance',
|
|
current_balance: currentBalance,
|
|
required: body.amount,
|
|
}, { status: 400 });
|
|
}
|
|
|
|
// 트랜잭션: 잔액 차감 + 거래 기록
|
|
await env.DB.batch([
|
|
env.DB.prepare(
|
|
'UPDATE user_deposits SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
|
).bind(body.amount, user.id),
|
|
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),
|
|
]);
|
|
|
|
const newBalance = currentBalance - body.amount;
|
|
|
|
console.log(`[API] Deposit deducted: user=${body.telegram_id}, amount=${body.amount}, reason=${body.reason}`);
|
|
|
|
return Response.json({
|
|
success: true,
|
|
telegram_id: body.telegram_id,
|
|
deducted: body.amount,
|
|
previous_balance: currentBalance,
|
|
new_balance: newBalance,
|
|
});
|
|
} catch (error) {
|
|
console.error('[API] Deposit deduct error:', error);
|
|
return Response.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 테스트 API - 메시지 처리 후 응답 직접 반환
|
|
if (url.pathname === '/api/test' && request.method === 'POST') {
|
|
try {
|
|
const body = await request.json() as { text: string; user_id?: string; secret?: string };
|
|
|
|
// 간단한 인증
|
|
if (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);
|
|
|
|
// 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) {
|
|
console.error('[Test API] Error:', error);
|
|
return Response.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 문의 폼 API (웹사이트용)
|
|
if (url.pathname === '/api/contact' && request.method === 'POST') {
|
|
// CORS: hosting.anvil.it.com만 허용
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
|
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
};
|
|
|
|
try {
|
|
const body = await request.json() as {
|
|
email: string;
|
|
message: string;
|
|
};
|
|
|
|
// 필수 필드 검증
|
|
if (!body.email || !body.message) {
|
|
return Response.json(
|
|
{ error: '이메일과 메시지는 필수 항목입니다.' },
|
|
{ status: 400, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// 이메일 형식 검증
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(body.email)) {
|
|
return Response.json(
|
|
{ error: '올바른 이메일 형식이 아닙니다.' },
|
|
{ status: 400, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// 메시지 길이 제한
|
|
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}`
|
|
);
|
|
}
|
|
|
|
console.log(`[Contact] 문의 수신: ${body.email}`);
|
|
|
|
return Response.json(
|
|
{ success: true, message: '문의가 성공적으로 전송되었습니다.' },
|
|
{ headers: corsHeaders }
|
|
);
|
|
} catch (error) {
|
|
console.error('[Contact] 오류:', error);
|
|
return Response.json(
|
|
{ error: '문의 전송 중 오류가 발생했습니다.' },
|
|
{ status: 500, headers: corsHeaders }
|
|
);
|
|
}
|
|
}
|
|
|
|
// CORS preflight for contact API
|
|
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
|
|
return new Response(null, {
|
|
headers: {
|
|
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
|
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
|
|
// Telegram Webhook 처리
|
|
if (url.pathname === '/webhook') {
|
|
// 보안 검증
|
|
const validation = await validateWebhookRequest(request, env);
|
|
|
|
if (!validation.valid) {
|
|
console.error('Webhook validation failed:', validation.error);
|
|
return new Response(validation.error, { status: 401 });
|
|
}
|
|
|
|
try {
|
|
const update = validation.update!;
|
|
|
|
// Callback Query 처리 (인라인 버튼 클릭)
|
|
if (update.callback_query) {
|
|
await handleCallbackQuery(env, update.callback_query);
|
|
return new Response('OK');
|
|
}
|
|
|
|
// 일반 메시지 처리
|
|
await handleMessage(env, update);
|
|
return new Response('OK');
|
|
} catch (error) {
|
|
console.error('Message handling error:', error);
|
|
return new Response('Error', { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 루트 경로
|
|
if (url.pathname === '/') {
|
|
return new Response(`
|
|
Telegram Rolling Summary Bot
|
|
|
|
Endpoints:
|
|
GET /health - Health check
|
|
GET /webhook-info - Webhook status
|
|
GET /setup-webhook - Configure webhook
|
|
POST /webhook - Telegram webhook (authenticated)
|
|
|
|
Documentation: https://github.com/your-repo
|
|
`.trim(), {
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
});
|
|
}
|
|
|
|
return new Response('Not Found', { status: 404 });
|
|
},
|
|
|
|
// Email 핸들러 (SMS → 메일 → 파싱)
|
|
async email(message: EmailMessage, env: Env): Promise<void> {
|
|
try {
|
|
// 이메일 본문 읽기
|
|
const rawEmail = await new Response(message.raw).text();
|
|
console.log('[Email] 수신:', message.from, 'Size:', message.rawSize);
|
|
|
|
// SMS 내용 파싱
|
|
const notification = parseBankSMS(rawEmail);
|
|
if (!notification) {
|
|
console.log('[Email] 은행 SMS 파싱 실패');
|
|
return;
|
|
}
|
|
|
|
console.log('[Email] 파싱 결과:', notification);
|
|
|
|
// DB에 저장
|
|
const insertResult = await env.DB.prepare(
|
|
`INSERT INTO bank_notifications (bank_name, depositor_name, amount, balance_after, transaction_time, raw_message)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
).bind(
|
|
notification.bankName,
|
|
notification.depositorName,
|
|
notification.amount,
|
|
notification.balanceAfter || null,
|
|
notification.transactionTime?.toISOString() || null,
|
|
notification.rawMessage
|
|
).run();
|
|
|
|
const notificationId = insertResult.meta.last_row_id;
|
|
console.log('[Email] 알림 저장 완료, ID:', notificationId);
|
|
|
|
// 자동 매칭 시도
|
|
const matched = await tryAutoMatch(env.DB, notificationId, notification);
|
|
|
|
// 매칭 성공 시 사용자에게 알림
|
|
if (matched && env.BOT_TOKEN) {
|
|
const user = await env.DB.prepare(
|
|
'SELECT telegram_id FROM users WHERE id = ?'
|
|
).bind(matched.userId).first<{ telegram_id: string }>();
|
|
|
|
if (user) {
|
|
// 업데이트된 잔액 조회
|
|
const deposit = await env.DB.prepare(
|
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
|
).bind(matched.userId).first<{ balance: number }>();
|
|
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
parseInt(user.telegram_id),
|
|
`✅ <b>입금 확인 완료!</b>\n\n` +
|
|
`입금액: ${matched.amount.toLocaleString()}원\n` +
|
|
`현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n\n` +
|
|
`감사합니다! 🎉`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 관리자에게 알림
|
|
if (env.BOT_TOKEN && env.DEPOSIT_ADMIN_ID) {
|
|
const statusMsg = matched
|
|
? `✅ 자동 매칭 완료! (거래 #${matched.transactionId})`
|
|
: '⏳ 매칭 대기 중 (사용자 입금 신고 필요)';
|
|
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
parseInt(env.DEPOSIT_ADMIN_ID),
|
|
`🏦 <b>입금 알림</b>\n\n` +
|
|
`은행: ${notification.bankName}\n` +
|
|
`입금자: ${notification.depositorName}\n` +
|
|
`금액: ${notification.amount.toLocaleString()}원\n` +
|
|
`${notification.balanceAfter ? `잔액: ${notification.balanceAfter.toLocaleString()}원\n` : ''}` +
|
|
`\n${statusMsg}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('[Email] 처리 오류:', error);
|
|
}
|
|
},
|
|
|
|
// Cron Trigger: 만료된 입금 대기 자동 취소 (24시간)
|
|
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
console.log('[Cron] 만료된 입금 대기 정리 시작');
|
|
|
|
try {
|
|
// 24시간 이상 된 pending 거래 조회
|
|
const expiredTxs = await env.DB.prepare(
|
|
`SELECT dt.id, dt.amount, dt.depositor_name, u.telegram_id
|
|
FROM deposit_transactions dt
|
|
JOIN users u ON dt.user_id = u.id
|
|
WHERE dt.status = 'pending'
|
|
AND dt.type = 'deposit'
|
|
AND datetime(dt.created_at) < datetime('now', '-1 day')
|
|
LIMIT 100`
|
|
).all<{
|
|
id: number;
|
|
amount: number;
|
|
depositor_name: string;
|
|
telegram_id: string;
|
|
}>();
|
|
|
|
if (!expiredTxs.results?.length) {
|
|
console.log('[Cron] 만료된 거래 없음');
|
|
return;
|
|
}
|
|
|
|
console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`);
|
|
|
|
for (const tx of expiredTxs.results) {
|
|
// 상태를 cancelled로 변경
|
|
await env.DB.prepare(
|
|
"UPDATE deposit_transactions SET status = 'cancelled', description = '입금 대기 만료 (24시간)' WHERE id = ?"
|
|
).bind(tx.id).run();
|
|
|
|
// 사용자에게 알림
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
parseInt(tx.telegram_id),
|
|
`⏰ <b>입금 대기 만료</b>\n\n` +
|
|
`입금자: ${tx.depositor_name}\n` +
|
|
`금액: ${tx.amount.toLocaleString()}원\n\n` +
|
|
`24시간 이내에 입금이 확인되지 않아 자동 취소되었습니다.\n` +
|
|
`다시 입금하시려면 입금 후 알려주세요.`
|
|
);
|
|
|
|
console.log(`[Cron] 거래 #${tx.id} 만료 처리 완료`);
|
|
}
|
|
|
|
console.log('[Cron] 만료된 입금 대기 정리 완료');
|
|
} catch (error) {
|
|
console.error('[Cron] 오류:', error);
|
|
}
|
|
},
|
|
};
|
|
|
|
// Quoted-Printable UTF-8 디코딩
|
|
function decodeQuotedPrintableUTF8(str: string): string {
|
|
// 줄 연속 문자 제거
|
|
str = str.replace(/=\r?\n/g, '');
|
|
|
|
// =XX 패턴을 바이트로 변환
|
|
const bytes: number[] = [];
|
|
let i = 0;
|
|
while (i < str.length) {
|
|
if (str[i] === '=' && i + 2 < str.length) {
|
|
const hex = str.slice(i + 1, i + 3);
|
|
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
|
|
bytes.push(parseInt(hex, 16));
|
|
i += 3;
|
|
continue;
|
|
}
|
|
}
|
|
bytes.push(str.charCodeAt(i));
|
|
i++;
|
|
}
|
|
|
|
// UTF-8 바이트를 문자열로 변환
|
|
try {
|
|
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
|
} catch {
|
|
return str;
|
|
}
|
|
}
|
|
|
|
// 은행 SMS 파싱 함수
|
|
function parseBankSMS(content: string): BankNotification | null {
|
|
// MIME 이메일 전처리
|
|
let text = content;
|
|
|
|
// Quoted-Printable UTF-8 디코딩
|
|
text = decodeQuotedPrintableUTF8(text);
|
|
|
|
// HTML <br/> 태그를 줄바꿈으로 변환
|
|
text = text.replace(/<br\s*\/?>/gi, '\n');
|
|
|
|
// 줄바꿈 정규화
|
|
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
|
|
// [Web발신] 또는 은행 키워드가 있는 부분만 추출
|
|
const smsStartMatch = text.match(/\[Web발신\]|\[하나은행\]|\[KB\]|\[신한\]|\[우리\]|\[농협\]/);
|
|
if (smsStartMatch && smsStartMatch.index !== undefined) {
|
|
// SMS 시작점부터 500자 추출
|
|
text = text.slice(smsStartMatch.index, smsStartMatch.index + 500);
|
|
}
|
|
|
|
// 하나은행 Web발신 패턴 (여러 줄):
|
|
// [Web발신]
|
|
// 하나,01/16, 22:12
|
|
// 427******27104
|
|
// 입금1원
|
|
// 황병하
|
|
const hanaWebPattern = /\[Web발신\]\s*하나[,\s]*(\d{1,2}\/\d{1,2})[,\s]*(\d{1,2}:\d{2})\s*[\d*]+\s*입금([\d,]+)원\s*(\S+)/;
|
|
const hanaWebMatch = text.match(hanaWebPattern);
|
|
if (hanaWebMatch) {
|
|
const [, date, time, amountStr, depositor] = hanaWebMatch;
|
|
return {
|
|
bankName: '하나은행',
|
|
depositorName: depositor.trim(),
|
|
amount: parseInt(amountStr.replace(/,/g, '')),
|
|
transactionTime: parseDateTime(date, time),
|
|
rawMessage: text.slice(0, 500),
|
|
};
|
|
}
|
|
|
|
// 하나은행 기존 패턴: [하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원
|
|
const hanaPattern = /\[하나은행\]\s*(\d{1,2}\/\d{1,2})\s*(\d{1,2}:\d{2})?\s*입금\s*([\d,]+)원\s*(\S+?)(?:\s+잔액\s*([\d,]+)원)?/;
|
|
const hanaMatch = text.match(hanaPattern);
|
|
if (hanaMatch) {
|
|
const [, date, time, amountStr, depositor, balanceStr] = hanaMatch;
|
|
return {
|
|
bankName: '하나은행',
|
|
depositorName: depositor,
|
|
amount: parseInt(amountStr.replace(/,/g, '')),
|
|
balanceAfter: balanceStr ? parseInt(balanceStr.replace(/,/g, '')) : undefined,
|
|
transactionTime: parseDateTime(date, time),
|
|
rawMessage: text.slice(0, 500),
|
|
};
|
|
}
|
|
|
|
// KB국민은행 패턴: [KB] 입금 50,000원 01/16 14:30 홍길동
|
|
const kbPattern = /\[KB\]\s*입금\s*([\d,]+)원\s*(\d{1,2}\/\d{1,2})?\s*(\d{1,2}:\d{2})?\s*(\S+)/;
|
|
const kbMatch = text.match(kbPattern);
|
|
if (kbMatch) {
|
|
const [, amountStr, date, time, depositor] = kbMatch;
|
|
return {
|
|
bankName: 'KB국민은행',
|
|
depositorName: depositor,
|
|
amount: parseInt(amountStr.replace(/,/g, '')),
|
|
transactionTime: date ? parseDateTime(date, time) : undefined,
|
|
rawMessage: text.slice(0, 500),
|
|
};
|
|
}
|
|
|
|
// 신한은행 패턴: [신한] 01/16 입금 50,000원 홍길동
|
|
const shinhanPattern = /\[신한\]\s*(\d{1,2}\/\d{1,2})?\s*입금\s*([\d,]+)원\s*(\S+)/;
|
|
const shinhanMatch = text.match(shinhanPattern);
|
|
if (shinhanMatch) {
|
|
const [, date, amountStr, depositor] = shinhanMatch;
|
|
return {
|
|
bankName: '신한은행',
|
|
depositorName: depositor,
|
|
amount: parseInt(amountStr.replace(/,/g, '')),
|
|
transactionTime: date ? parseDateTime(date) : undefined,
|
|
rawMessage: text.slice(0, 500),
|
|
};
|
|
}
|
|
|
|
// 일반 입금 패턴: 입금 50,000원 홍길동 또는 홍길동 50,000원 입금
|
|
const genericPattern1 = /입금\s*([\d,]+)원?\s*(\S{2,10})/;
|
|
const genericPattern2 = /(\S{2,10})\s*([\d,]+)원?\s*입금/;
|
|
|
|
const genericMatch1 = text.match(genericPattern1);
|
|
if (genericMatch1) {
|
|
return {
|
|
bankName: '알수없음',
|
|
depositorName: genericMatch1[2],
|
|
amount: parseInt(genericMatch1[1].replace(/,/g, '')),
|
|
rawMessage: text.slice(0, 500),
|
|
};
|
|
}
|
|
|
|
const genericMatch2 = text.match(genericPattern2);
|
|
if (genericMatch2) {
|
|
return {
|
|
bankName: '알수없음',
|
|
depositorName: genericMatch2[1],
|
|
amount: parseInt(genericMatch2[2].replace(/,/g, '')),
|
|
rawMessage: text.slice(0, 500),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 날짜/시간 파싱
|
|
function parseDateTime(dateStr: string, timeStr?: string): Date {
|
|
const now = new Date();
|
|
const [month, day] = dateStr.split('/').map(Number);
|
|
const year = now.getFullYear();
|
|
|
|
let hours = 0, minutes = 0;
|
|
if (timeStr) {
|
|
[hours, minutes] = timeStr.split(':').map(Number);
|
|
}
|
|
|
|
return new Date(year, month - 1, day, hours, minutes);
|
|
}
|
|
|
|
// 자동 매칭 시도
|
|
async function tryAutoMatch(
|
|
db: D1Database,
|
|
notificationId: number,
|
|
notification: BankNotification
|
|
): Promise<{ transactionId: number; userId: number; amount: number } | null> {
|
|
// 매칭 조건: 입금자명(앞 7글자) + 금액이 일치하는 pending 거래
|
|
// 은행 SMS는 입금자명이 7글자까지만 표시됨
|
|
const pendingTx = await db.prepare(
|
|
`SELECT dt.id, dt.user_id, dt.amount
|
|
FROM deposit_transactions dt
|
|
WHERE dt.status = 'pending'
|
|
AND dt.type = 'deposit'
|
|
AND SUBSTR(dt.depositor_name, 1, 7) = ?
|
|
AND dt.amount = ?
|
|
ORDER BY dt.created_at ASC
|
|
LIMIT 1`
|
|
).bind(notification.depositorName, notification.amount).first<{
|
|
id: number;
|
|
user_id: number;
|
|
amount: number;
|
|
}>();
|
|
|
|
if (!pendingTx) {
|
|
console.log('[AutoMatch] 매칭되는 pending 거래 없음');
|
|
return null;
|
|
}
|
|
|
|
console.log('[AutoMatch] 매칭 발견:', pendingTx);
|
|
|
|
// 트랜잭션: 거래 확정 + 잔액 증가 + 알림 매칭 업데이트
|
|
await db.batch([
|
|
db.prepare(
|
|
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?"
|
|
).bind(pendingTx.id),
|
|
db.prepare(
|
|
'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
|
).bind(pendingTx.amount, pendingTx.user_id),
|
|
db.prepare(
|
|
'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?'
|
|
).bind(pendingTx.id, notificationId),
|
|
]);
|
|
|
|
return { transactionId: pendingTx.id, userId: pendingTx.user_id, amount: pendingTx.amount };
|
|
}
|