fix: Email Routing MIME 파싱 개선 + 레거시 코드 정리

- Email Routing에서 수신한 이메일 파싱 수정
  - Quoted-Printable UTF-8 디코딩 함수 추가
  - HTML <br/> 태그를 줄바꿈으로 변환
  - SMS 키워드 위치 기반 본문 추출

- 레거시 코드 삭제
  - /api/bank-notification 엔드포인트 제거 (Email Routing으로 대체)
  - BANK_API_SECRET 관련 코드 및 문서 제거
  - DEPOSIT_AGENT_ID 제거 (Assistants API → 코드 직접 처리)

- CLI 테스트 클라이언트 개선
  - .env 파일 자동 로드 지원
  - WEBHOOK_SECRET 환경변수 불필요

- 문서 업데이트
  - NAMECHEAP_API_KEY 설명 명확화 (래퍼 인증 키)
  - CLI 테스트 섹션 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-18 13:12:26 +09:00
parent edbd790538
commit 89f8ea19f1
8 changed files with 156 additions and 215 deletions

View File

@@ -1,5 +1,8 @@
/**
* Deposit Agent - 예치금 관리 에이전트 (OpenAI Assistants API)
* Deposit Agent - 예치금 관리 (코드 직접 처리)
*
* 변경 이력:
* - 2026-01: Assistants API → 코드 직접 처리로 변경 (지역 제한 우회, 응답 일관성)
*
* 기능:
* - 잔액 조회

View File

@@ -169,112 +169,6 @@ export default {
}
}
// Bank Notification API (Gmail → Apps Script → Worker)
if (url.pathname === '/api/bank-notification' && request.method === 'POST') {
try {
const body = await request.json() as { content: string; secret?: string; messageId?: string };
// 간단한 인증 (BANK_API_SECRET 또는 WEBHOOK_SECRET 사용)
const apiSecret = (env as any).BANK_API_SECRET || env.WEBHOOK_SECRET;
if (apiSecret && body.secret !== apiSecret) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
console.log('[API] Bank notification received:', body.content?.slice(0, 100));
// 메일 ID로 중복 체크
if (body.messageId) {
const existing = await env.DB.prepare(
'SELECT id FROM bank_notifications WHERE message_id = ?'
).bind(body.messageId).first();
if (existing) {
console.log('[API] 중복 메일 무시:', body.messageId);
return Response.json({ success: true, duplicate: true, messageId: body.messageId });
}
}
// SMS 파싱
const notification = parseBankSMS(body.content || '');
if (!notification) {
console.log('[API] 파싱 실패:', body.content);
return Response.json({ error: 'Parse failed', content: body.content }, { status: 400 });
}
console.log('[API] 파싱 결과:', notification);
// DB에 저장
const insertResult = await env.DB.prepare(
`INSERT INTO bank_notifications (bank_name, depositor_name, amount, balance_after, transaction_time, raw_message, message_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).bind(
notification.bankName,
notification.depositorName,
notification.amount,
notification.balanceAfter || null,
notification.transactionTime?.toISOString() || null,
notification.rawMessage,
body.messageId || null
).run();
const notificationId = insertResult.meta.last_row_id;
console.log('[API] 알림 저장 완료, ID:', notificationId);
// 자동 매칭 시도
const matched = await tryAutoMatch(env.DB, notificationId as number, 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}`
);
}
return Response.json({
success: true,
notification,
matched: !!matched
});
} catch (error) {
console.error('[API] Bank notification error:', error);
return Response.json({ error: String(error) }, { status: 500 });
}
}
// Deposit API - 잔액 조회 (namecheap-api 전용)
if (url.pathname === '/api/deposit/balance' && request.method === 'GET') {
try {
@@ -497,7 +391,7 @@ Documentation: https://github.com/your-repo
// SMS 내용 파싱
const notification = parseBankSMS(rawEmail);
if (!notification) {
console.log('[Email] 은행 SMS 파싱 실패:', rawEmail.slice(0, 200));
console.log('[Email] 은행 SMS 파싱 실패');
return;
}
@@ -568,10 +462,55 @@ Documentation: https://github.com/your-repo
},
};
// 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 {
// 이메일에서 SMS 본문 추출 (여러 줄에 걸쳐 있을 수 있음)
const text = content.replace(/\r\n/g, '\n').replace(/=\n/g, '');
// 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발신]

View File

@@ -253,8 +253,7 @@ function selectToolsForMessage(message: string): typeof tools {
}
// 도메인 추천 함수
async function suggestDomains(keywords: string, apiKey: string): Promise<string> {
const namecheapApiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
async function suggestDomains(keywords: string, apiKey: string, namecheapApiKey: string): Promise<string> {
const namecheapApiUrl = 'https://namecheap-api.anvil.it.com';
const TARGET_COUNT = 10;
const MAX_RETRIES = 3;
@@ -1123,11 +1122,15 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
console.log('[suggest_domains] 시작:', { keywords });
if (!env?.OPENAI_API_KEY) {
return '🚫 도메인 추천 기능이 설정되지 않았습니다.';
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (OPENAI_API_KEY 미설정)';
}
if (!env?.NAMECHEAP_API_KEY) {
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (NAMECHEAP_API_KEY 미설정)';
}
try {
const result = await suggestDomains(keywords, env.OPENAI_API_KEY);
const result = await suggestDomains(keywords, env.OPENAI_API_KEY, env.NAMECHEAP_API_KEY);
console.log('[suggest_domains] 완료:', result?.slice(0, 100));
return result;
} catch (error) {

View File

@@ -9,7 +9,6 @@ export interface Env {
OPENAI_API_KEY?: string;
NAMECHEAP_API_KEY?: string;
DOMAIN_OWNER_ID?: string;
DEPOSIT_AGENT_ID?: string;
DEPOSIT_ADMIN_ID?: string;
BRAVE_API_KEY?: string;
DEPOSIT_API_SECRET?: string;