feat(domain): enhance domain info lookup & handler refactoring
- 도메인 조회(info): 내 도메인 아니면 자동으로 WHOIS 조회 (naver.com 등 지원) - SMS 파싱: 정규식 실패 시 AI 폴백 로직 추가 - 리팩토링: UserService, ConversationService 분리 - 문서: README.md 및 CODE_REVIEW.md 업데이트
This commit is contained in:
22
CODE_REVIEW.md
Normal file
22
CODE_REVIEW.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Telegram Bot Code Review
|
||||
Date: 2026-01-19
|
||||
|
||||
## Summary
|
||||
The project demonstrates a high-quality, modern architecture leveraging Cloudflare Workers, D1, KV, and AI.
|
||||
|
||||
## 1. Strengths
|
||||
- **Security Design**: The Webhook Secret verification logic in `src/security.ts` is implemented using timing-safe comparison, making it robust against timing attacks.
|
||||
- **AI Context Management**: The **Rolling Summary** approach in `src/summary-service.ts` is impressive. It efficiently maintains user context by periodically summarizing conversations, optimizing token usage.
|
||||
- **Separation of Concerns**: The project structure clearly isolates APIs, Webhooks, Service Logic, and Tools, facilitating easy functional expansion.
|
||||
|
||||
## 2. Improvements Needed
|
||||
- **SMS Parsing Robustness**: The regex-based parsing in `src/services/bank-sms-parser.ts` is brittle and may fail if bank message formats change.
|
||||
- *Action*: Implement an AI-based fallback mechanism to parse unstructured messages when regex fails.
|
||||
- **Handler Bloat**: `handleMessage` in `src/routes/webhook.ts` handles too many responsibilities (user lookup, buffering, AI generation).
|
||||
- *Action*: Refactor into separate service classes.
|
||||
- **Monitoring**: While `logger.ts` and `metrics.ts` exist, adding business metrics like deposit match rates or AI latency would improve operational visibility.
|
||||
|
||||
## 3. Architecture Score
|
||||
- **Design**: 95/100 (Excellent use of Cloudflare ecosystem)
|
||||
- **Security**: 98/100 (Strong Webhook & Rate Limit implementation)
|
||||
- **Maintainability**: 85/100 (Handler refactoring recommended)
|
||||
20
README.md
20
README.md
@@ -29,8 +29,8 @@
|
||||
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
||||
- **동적 도구 로딩**: 메시지 키워드 기반으로 필요한 도구만 선택하여 토큰 절약
|
||||
- **도메인 추천**: GPT가 창의적 도메인 생성 → 가용성 자동 확인 → 가격과 함께 제안
|
||||
- **예치금 시스템**: 코드 직접 처리, 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전
|
||||
- **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리
|
||||
- **예치금 시스템**: 코드 직접 처리, 은행 입금 자동 감지(SMS/AI 파싱) + 사용자 신고 매칭으로 자동 충전
|
||||
- **Email Worker**: SMS → 메일 → 자동 파싱(Regex + AI 폴백)으로 입금 알림 처리
|
||||
- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억
|
||||
- **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공
|
||||
- **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환
|
||||
@@ -233,7 +233,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SMS 파싱 │ ← 입금자명, 금액, 은행 추출
|
||||
│ SMS 파싱 │ ← 입금자명, 금액, 은행 추출 (AI 폴백 지원)
|
||||
│ (하나/KB/신한) │
|
||||
└──────────────────┘
|
||||
│
|
||||
@@ -271,7 +271,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
|
||||
"입금할게요" → 계좌 정보 안내
|
||||
|
||||
# 입금 신고 (자연어 금액 인식 지원)
|
||||
"홍길동 5000원 입금했어" → 즉시 처리
|
||||
"홍길동 50000원 입금했어" → 즉시 처리
|
||||
"홍길동 만원 입금" → 10,000원으로 인식
|
||||
"홍길동 5천원" → 5,000원으로 인식
|
||||
|
||||
@@ -283,6 +283,7 @@ URL: gateway.ai.cloudflare.com/v1/{account_id}/telegram-bot/openai/...
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- 🔍 **AI 파싱**: 정형화되지 않은 은행 문자도 AI가 자동 분석하여 처리
|
||||
- 🔢 **자연어 금액**: "만원", "5천원", "삼만오천원" 등 자동 변환
|
||||
- ⚡ **즉시 실행**: 입금자명+금액 있으면 확인 없이 바로 처리
|
||||
- 📋 **동시 요청**: 기존 pending 있어도 새 입금 신고 가능
|
||||
@@ -330,6 +331,7 @@ SMS를 메일로 전달받아 Worker에서 직접 처리합니다.
|
||||
- 하나은행 (기존): `[하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원`
|
||||
- KB국민: `[KB] 입금 50,000원 01/16 14:30 홍길동`
|
||||
- 신한: `[신한] 01/16 입금 50,000원 홍길동`
|
||||
- **AI 자동 인식**: 패턴 미일치 시 AI가 내용 분석하여 자동 추출
|
||||
|
||||
---
|
||||
|
||||
@@ -342,7 +344,7 @@ SMS를 메일로 전달받아 Worker에서 직접 처리합니다.
|
||||
| 기능 | 설명 | 권한 |
|
||||
|------|------|------|
|
||||
| `도메인 목록` | 내 도메인 목록 조회 | 소유자 |
|
||||
| `도메인 정보` | 도메인 상세 정보 (만료일 등) | 소유자 |
|
||||
| `도메인 정보` | 도메인 상세 정보 (내 도메인 아니면 WHOIS 자동 조회) | 누구나 |
|
||||
| `네임서버 조회` | 현재 네임서버 확인 | 누구나 |
|
||||
| `네임서버 변경` | 네임서버 설정 변경 | 소유자 |
|
||||
| `가격 조회` | TLD/ccSLD 등록 가격 (원화) | 누구나 |
|
||||
@@ -369,7 +371,7 @@ Namecheap 가격 + 13% 마진, 매일 환율 업데이트
|
||||
자체 WHOIS API 서버(Vercel)를 통해 TCP 43 포트로 직접 쿼리
|
||||
|
||||
```
|
||||
사용자: "google.com whois"
|
||||
사용자: "google.com whois" 또는 "google.com 정보"
|
||||
봇: 등록일, 만료일, 네임서버, 등록기관 정보 표시
|
||||
```
|
||||
|
||||
@@ -483,6 +485,10 @@ telegram-bot-workers/
|
||||
│ ├── deposit-agent.ts # 예치금 에이전트 (Assistants API)
|
||||
│ ├── n8n-service.ts # n8n 연동 (선택)
|
||||
│ └── commands.ts # 봇 명령어 핸들러
|
||||
├── src/services/
|
||||
│ ├── bank-sms-parser.ts # SMS 파싱 로직 (Regex + AI)
|
||||
│ ├── user-service.ts # 사용자 관리
|
||||
│ └── conversation-service.ts # 대화 로직
|
||||
├── schema.sql # D1 스키마
|
||||
├── wrangler.toml # Wrangler 설정
|
||||
├── n8n-workflow-example.json # n8n 워크플로우 예시
|
||||
@@ -705,3 +711,5 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
||||
## 소스 코드
|
||||
|
||||
**Gitea**: https://gitea.anvil.it.com/kaffa/telegram-bot-workers
|
||||
|
||||
```
|
||||
1538
package-lock.json
generated
Normal file
1538
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241127.0",
|
||||
"typescript": "^5.3.3",
|
||||
"wrangler": "^3.93.0"
|
||||
"wrangler": "^4.59.2"
|
||||
},
|
||||
"keywords": [
|
||||
"telegram",
|
||||
|
||||
@@ -77,7 +77,7 @@ Documentation: https://github.com/your-repo
|
||||
console.log('[Email] 수신:', message.from, 'Size:', message.rawSize);
|
||||
|
||||
// SMS 내용 파싱
|
||||
const notification = parseBankSMS(rawEmail);
|
||||
const notification = await parseBankSMS(rawEmail, env);
|
||||
if (!notification) {
|
||||
console.log('[Email] 은행 SMS 파싱 실패');
|
||||
return;
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import { Env, TelegramUpdate } from '../types';
|
||||
import { validateWebhookRequest, checkRateLimit } from '../security';
|
||||
import { sendMessage, sendMessageWithKeyboard, sendChatAction, answerCallbackQuery, editMessageText } from '../telegram';
|
||||
import { sendMessage, sendMessageWithKeyboard, answerCallbackQuery, editMessageText } from '../telegram';
|
||||
import { executeDomainRegister } from '../domain-register';
|
||||
import {
|
||||
addToBuffer,
|
||||
processAndSummarize,
|
||||
generateAIResponse,
|
||||
} from '../summary-service';
|
||||
import { handleCommand } from '../commands';
|
||||
import { UserService } from '../services/user-service';
|
||||
import { ConversationService } from '../services/conversation-service';
|
||||
|
||||
// 사용자 조회/생성
|
||||
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
|
||||
@@ -49,10 +16,10 @@ async function handleMessage(
|
||||
const { message } = update;
|
||||
const chatId = message.chat.id;
|
||||
const chatIdStr = chatId.toString();
|
||||
const text = message.text!; // Already checked above
|
||||
const text = message.text!;
|
||||
const telegramUserId = message.from.id.toString();
|
||||
|
||||
// Rate Limiting 체크 (KV 기반)
|
||||
// 1. Rate Limiting 체크
|
||||
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
@@ -62,11 +29,14 @@ async function handleMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 처리 (오류 시 사용자에게 알림)
|
||||
// 2. 서비스 인스턴스 초기화
|
||||
const userService = new UserService(env.DB);
|
||||
const conversationService = new ConversationService(env);
|
||||
|
||||
// 3. 사용자 조회/생성
|
||||
let userId: number;
|
||||
try {
|
||||
userId = await getOrCreateUser(
|
||||
env.DB,
|
||||
userId = await userService.getOrCreateUser(
|
||||
telegramUserId,
|
||||
message.from.first_name,
|
||||
message.from.username
|
||||
@@ -81,14 +51,12 @@ async function handleMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
let responseText: string;
|
||||
|
||||
try {
|
||||
// 명령어 처리
|
||||
// 4. 명령어 처리
|
||||
if (text.startsWith('/')) {
|
||||
const [command, ...argParts] = text.split(' ');
|
||||
const args = argParts.join(' ');
|
||||
responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
||||
const responseText = await handleCommand(env, userId, chatIdStr, command, args);
|
||||
|
||||
// /start 명령어는 미니앱 버튼과 함께 전송
|
||||
if (command === '/start') {
|
||||
@@ -98,55 +66,47 @@ async function handleMessage(
|
||||
]);
|
||||
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>';
|
||||
}
|
||||
|
||||
await sendMessage(env.BOT_TOKEN, chatId, responseText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 일반 대화 처리 (ConversationService 위임)
|
||||
const result = await conversationService.processUserMessage(
|
||||
userId,
|
||||
chatIdStr,
|
||||
text,
|
||||
telegramUserId
|
||||
);
|
||||
|
||||
let finalResponse = result.responseText;
|
||||
if (result.isProfileUpdated) {
|
||||
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||
}
|
||||
|
||||
// 6. 응답 전송 (키보드 포함 여부 확인)
|
||||
if (result.keyboardData && result.keyboardData.type === 'domain_register') {
|
||||
const { domain, price } = result.keyboardData;
|
||||
const callbackData = `domain_reg:${domain}:${price}`;
|
||||
|
||||
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [
|
||||
[
|
||||
{ text: '✅ 등록하기', callback_data: callbackData },
|
||||
{ text: '❌ 취소', callback_data: 'domain_cancel' }
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
await sendMessage(env.BOT_TOKEN, chatId, finalResponse);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[handleMessage] 처리 오류:', error);
|
||||
responseText = '⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 데이터 파싱
|
||||
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 처리 (인라인 버튼 클릭)
|
||||
@@ -166,10 +126,8 @@ async function handleCallbackQuery(
|
||||
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 }>();
|
||||
const userService = new UserService(env.DB);
|
||||
const user = await userService.getUserByTelegramId(telegramUserId);
|
||||
|
||||
if (!user) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
|
||||
@@ -187,7 +145,6 @@ async function handleCallbackQuery(
|
||||
const domain = parts[1];
|
||||
const price = parseInt(parts[2]);
|
||||
|
||||
// 처리 중 표시
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
@@ -196,7 +153,6 @@ async function handleCallbackQuery(
|
||||
`⏳ <b>${domain}</b> 등록 처리 중...`
|
||||
);
|
||||
|
||||
// 도메인 등록 실행
|
||||
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
|
||||
|
||||
if (result.success) {
|
||||
@@ -250,17 +206,8 @@ ${result.error}
|
||||
|
||||
/**
|
||||
* Telegram Webhook 요청 처리
|
||||
*
|
||||
* Manual Test:
|
||||
* 1. wrangler dev
|
||||
* 2. curl -X POST http://localhost:8787/webhook \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Telegram-Bot-Api-Secret-Token: test-secret" \
|
||||
* -d '{"message":{"chat":{"id":123},"text":"테스트"}}'
|
||||
* 3. Expected: OK response, message processed
|
||||
*/
|
||||
export async function handleWebhook(request: Request, env: Env): Promise<Response> {
|
||||
// 보안 검증
|
||||
const validation = await validateWebhookRequest(request, env);
|
||||
|
||||
if (!validation.valid) {
|
||||
@@ -271,17 +218,15 @@ export async function handleWebhook(request: Request, env: Env): Promise<Respons
|
||||
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('[Webhook] 메시지 처리 오류:', error);
|
||||
return new Response('Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,62 @@
|
||||
import { BankNotification } from '../types';
|
||||
import { Env, BankNotification } from '../types';
|
||||
import { parseQuotedPrintable } from '../utils/email-decoder';
|
||||
|
||||
/**
|
||||
* 은행 SMS 파싱 함수
|
||||
*
|
||||
* 지원 은행:
|
||||
* - 하나은행 (Web발신 + 기존 패턴)
|
||||
* - KB국민은행
|
||||
* - 신한은행
|
||||
* - 일반 패턴 (은행 불명)
|
||||
* 1단계: 정규표현식 기반 파싱 (빠름)
|
||||
* 2단계: 실패 시 AI 모델 기반 파싱 (느리지만 유연함)
|
||||
*
|
||||
* @param content - 이메일 원본 내용 (MIME 포함 가능)
|
||||
* @returns 파싱된 은행 알림 또는 null (파싱 실패)
|
||||
* @param content - 이메일 원본 내용
|
||||
* @param env - 환경 변수 (AI 사용 위함)
|
||||
* @returns 파싱된 은행 알림 또는 null
|
||||
*/
|
||||
export function parseBankSMS(content: string): BankNotification | null {
|
||||
// MIME 이메일 전처리
|
||||
export async function parseBankSMS(content: string, env?: Env): Promise<BankNotification | null> {
|
||||
// 1. 전처리 (MIME 디코딩 등)
|
||||
let text = preprocessText(content);
|
||||
|
||||
// 2. 정규표현식 파싱 시도
|
||||
const regexResult = parseWithRegex(text);
|
||||
if (regexResult) {
|
||||
return regexResult;
|
||||
}
|
||||
|
||||
// 3. AI 파싱 시도 (Fallback)
|
||||
if (env) {
|
||||
console.log('[BankSMS] 정규식 파싱 실패, AI 파싱 시도...');
|
||||
try {
|
||||
// AI 파싱은 비용/시간이 들므로 SMS 패턴이 의심될 때만 시도
|
||||
// "입금", "원" 같은 키워드가 있는지 확인
|
||||
if (text.includes('입금') && (text.includes('원') || text.match(/\d/))) {
|
||||
return await parseWithAI(text, env);
|
||||
} else {
|
||||
console.log('[BankSMS] 입금 키워드 없음, AI 파싱 건너뜀');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BankSMS] AI 파싱 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function preprocessText(content: string): string {
|
||||
let text = content;
|
||||
|
||||
// Quoted-Printable UTF-8 디코딩
|
||||
text = parseQuotedPrintable(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\]|\[신한\]|\[우리\]|\[농협\]/);
|
||||
const smsStartMatch = text.match(/ \[Web발신\]| \[하나은행\]| \[KB\]| \[신한\]| \[우리\]| \[농협\]/);
|
||||
if (smsStartMatch && smsStartMatch.index !== undefined) {
|
||||
// SMS 시작점부터 500자 추출
|
||||
text = text.slice(smsStartMatch.index, smsStartMatch.index + 500);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// 하나은행 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+)/;
|
||||
function parseWithRegex(text: string): BankNotification | null {
|
||||
// 하나은행 Web발신
|
||||
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;
|
||||
@@ -52,8 +69,8 @@ export function parseBankSMS(content: string): BankNotification | null {
|
||||
};
|
||||
}
|
||||
|
||||
// 하나은행 기존 패턴: [하나은행] 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 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;
|
||||
@@ -67,8 +84,8 @@ export function parseBankSMS(content: string): BankNotification | null {
|
||||
};
|
||||
}
|
||||
|
||||
// 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+)/;
|
||||
// KB국민은행
|
||||
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;
|
||||
@@ -81,8 +98,8 @@ export function parseBankSMS(content: string): BankNotification | null {
|
||||
};
|
||||
}
|
||||
|
||||
// 신한은행 패턴: [신한] 01/16 입금 50,000원 홍길동
|
||||
const shinhanPattern = /\[신한\]\s*(\d{1,2}\/\d{1,2})?\s*입금\s*([\d,]+)원\s*(\S+)/;
|
||||
// 신한은행
|
||||
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;
|
||||
@@ -95,8 +112,8 @@ export function parseBankSMS(content: string): BankNotification | null {
|
||||
};
|
||||
}
|
||||
|
||||
// 일반 입금 패턴: 입금 50,000원 홍길동 또는 홍길동 50,000원 입금
|
||||
const genericPattern1 = /입금\s*([\d,]+)원?\s*(\S{2,10})/;
|
||||
// 일반 패턴
|
||||
const genericPattern1 = /입금\s*([\d,]+)원?\s*(\S{2,10})/;
|
||||
const genericPattern2 = /(\S{2,10})\s*([\d,]+)원?\s*입금/;
|
||||
|
||||
const genericMatch1 = text.match(genericPattern1);
|
||||
@@ -122,13 +139,89 @@ export function parseBankSMS(content: string): BankNotification | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜/시간 파싱 헬퍼 함수
|
||||
*
|
||||
* @param dateStr - "MM/DD" 형식
|
||||
* @param timeStr - "HH:MM" 형식 (선택)
|
||||
* @returns Date 객체
|
||||
*/
|
||||
async function parseWithAI(text: string, env: Env): Promise<BankNotification | null> {
|
||||
const prompt = `
|
||||
아래 텍스트는 은행 입금 알림 문자 메시지입니다.
|
||||
이 텍스트에서 [은행명, 입금자명, 입금액, 거래시간] 정보를 추출하여 JSON 형식으로 반환하세요.
|
||||
|
||||
텍스트:
|
||||
"""
|
||||
${text.slice(0, 500)}
|
||||
"""
|
||||
|
||||
요구사항:
|
||||
1. JSON 형식만 반환하세요. (마크다운 코드블록 없이)
|
||||
2. 필드명: bankName, depositorName, amount (숫자), transactionTime (YYYY-MM-DDTHH:mm:ss 형식, 연도는 ${new Date().getFullYear()}년 기준)
|
||||
3. 추출할 수 없는 필드는 null로 설정하세요.
|
||||
4. "입금"이 아닌 "출금"이거나 광고/스팸 메시지라면 null을 반환하세요.
|
||||
`;
|
||||
|
||||
let jsonStr = '';
|
||||
|
||||
// 1. OpenAI 시도
|
||||
if (env.OPENAI_API_KEY) {
|
||||
try {
|
||||
const response = await fetch('https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 200,
|
||||
temperature: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as any;
|
||||
jsonStr = data.choices[0].message.content;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BankSMS] OpenAI 파싱 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Workers AI 시도 (OpenAI 실패 또는 미설정 시)
|
||||
if (!jsonStr && env.AI) {
|
||||
try {
|
||||
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct' as any, {
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 200,
|
||||
}) as any;
|
||||
jsonStr = response.response;
|
||||
} catch (e) {
|
||||
console.error('[BankSMS] Workers AI 파싱 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!jsonStr) return null;
|
||||
|
||||
try {
|
||||
// JSON 파싱 (마크다운 제거 등 정제)
|
||||
const cleanJson = jsonStr.replace(/```json|```/g, '').trim();
|
||||
if (cleanJson === 'null') return null;
|
||||
|
||||
const result = JSON.parse(cleanJson);
|
||||
|
||||
if (result.amount && result.depositorName) {
|
||||
return {
|
||||
bankName: result.bankName || '알수없음',
|
||||
depositorName: result.depositorName,
|
||||
amount: typeof result.amount === 'string' ? parseInt(result.amount.replace(/,/g, '')) : result.amount,
|
||||
transactionTime: result.transactionTime ? new Date(result.transactionTime) : undefined,
|
||||
rawMessage: text.slice(0, 500),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BankSMS] AI 응답 JSON 파싱 실패:', e, jsonStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateTime(dateStr: string, timeStr?: string): Date {
|
||||
const now = new Date();
|
||||
const [month, day] = dateStr.split('/').map(Number);
|
||||
@@ -140,4 +233,4 @@ function parseDateTime(dateStr: string, timeStr?: string): Date {
|
||||
}
|
||||
|
||||
return new Date(year, month - 1, day, hours, minutes);
|
||||
}
|
||||
}
|
||||
74
src/services/conversation-service.ts
Normal file
74
src/services/conversation-service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Env } from '../types';
|
||||
import {
|
||||
addToBuffer,
|
||||
processAndSummarize,
|
||||
generateAIResponse,
|
||||
} from '../summary-service';
|
||||
import { sendChatAction, sendMessage } from '../telegram';
|
||||
|
||||
export interface ConversationResult {
|
||||
responseText: string;
|
||||
isProfileUpdated: boolean;
|
||||
keyboardData?: any;
|
||||
}
|
||||
|
||||
export class ConversationService {
|
||||
constructor(private env: Env) {}
|
||||
|
||||
/**
|
||||
* 사용자 메시지를 처리하고 AI 응답을 생성합니다.
|
||||
* 버퍼링, AI 생성, 요약 프로세스를 포함합니다.
|
||||
*/
|
||||
async processUserMessage(
|
||||
userId: number,
|
||||
chatId: string,
|
||||
text: string,
|
||||
telegramUserId: string
|
||||
): Promise<ConversationResult> {
|
||||
// 1. 타이핑 액션 전송 (비동기로 실행, 기다리지 않음)
|
||||
sendChatAction(this.env.BOT_TOKEN, chatId, 'typing').catch(console.error);
|
||||
|
||||
// 2. 사용자 메시지 버퍼에 추가
|
||||
await addToBuffer(this.env.DB, userId, chatId, 'user', text);
|
||||
|
||||
// 3. AI 응답 생성
|
||||
let responseText = await generateAIResponse(
|
||||
this.env,
|
||||
userId,
|
||||
chatId,
|
||||
text,
|
||||
telegramUserId
|
||||
);
|
||||
|
||||
// 4. 봇 응답 버퍼에 추가 (키보드 데이터 마커 등은 그대로 저장)
|
||||
// 실제 사용자에게 보여질 텍스트만 저장하는 것이 좋으나,
|
||||
// 현재 구조상 전체를 저장하고 나중에 컨텍스트로 활용 시 정제될 수 있음
|
||||
await addToBuffer(this.env.DB, userId, chatId, 'bot', responseText);
|
||||
|
||||
// 5. 임계값 도달 시 프로필 업데이트 (요약)
|
||||
const { summarized } = await processAndSummarize(
|
||||
this.env,
|
||||
userId,
|
||||
chatId
|
||||
);
|
||||
|
||||
// 키보드 데이터 파싱
|
||||
let keyboardData: any = null;
|
||||
const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/);
|
||||
|
||||
if (keyboardMatch) {
|
||||
responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/, '');
|
||||
try {
|
||||
keyboardData = JSON.parse(keyboardMatch[1]);
|
||||
} catch (e) {
|
||||
console.error('[ConversationService] Keyboard parsing error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
responseText,
|
||||
isProfileUpdated: summarized,
|
||||
keyboardData
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/services/user-service.ts
Normal file
47
src/services/user-service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Env } from '../types';
|
||||
|
||||
export class UserService {
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
/**
|
||||
* Telegram ID로 사용자를 조회하거나 없으면 새로 생성합니다.
|
||||
* 마지막 활동 시간도 업데이트합니다.
|
||||
*/
|
||||
async getOrCreateUser(
|
||||
telegramId: string,
|
||||
firstName: string,
|
||||
username?: string
|
||||
): Promise<number> {
|
||||
const existing = await this.db
|
||||
.prepare('SELECT id FROM users WHERE telegram_id = ?')
|
||||
.bind(telegramId)
|
||||
.first<{ id: number }>();
|
||||
|
||||
if (existing) {
|
||||
// 마지막 활동 시간 업데이트
|
||||
await this.db
|
||||
.prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.bind(existing.id)
|
||||
.run();
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
// 새 사용자 생성
|
||||
const result = await this.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram ID로 사용자 정보를 조회합니다.
|
||||
*/
|
||||
async getUserByTelegramId(telegramId: string): Promise<{ id: number; first_name: string } | null> {
|
||||
return await this.db
|
||||
.prepare('SELECT id, first_name FROM users WHERE telegram_id = ?')
|
||||
.bind(telegramId)
|
||||
.first<{ id: number; first_name: string }>();
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export const manageDomainTool = {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['register', 'check', 'whois', 'list', 'info', 'get_ns', 'set_ns', 'price', 'cheapest'],
|
||||
description: 'price: TLD 가격 조회 (.com 가격, .io 가격), cheapest: 가장 저렴한 TLD 목록 조회, register: 도메인 등록, check: 가용성 확인, whois: WHOIS 조회, list: 내 도메인 목록, info: 도메인 상세정보, get_ns/set_ns: 네임서버 조회/변경',
|
||||
description: 'price: TLD 가격 조회 (.com 가격, .io 가격), cheapest: 가장 저렴한 TLD 목록 조회, register: 도메인 등록, check: 가용성 확인, whois: WHOIS 조회, list: 내 도메인 목록, info: 도메인 상세정보(내 도메인이 아니면 WHOIS 조회), get_ns/set_ns: 네임서버 조회/변경',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
@@ -416,8 +416,21 @@ async function executeDomainAction(
|
||||
|
||||
case 'info': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
|
||||
// 내 도메인이 아니면 WHOIS 조회로 자동 전환
|
||||
const lowerDomain = domain.toLowerCase();
|
||||
const isMyDomain = allowedDomains.some(d => d.toLowerCase() === lowerDomain);
|
||||
|
||||
if (!isMyDomain) {
|
||||
return executeDomainAction('whois', args, allowedDomains, env, telegramUserId, db, userId);
|
||||
}
|
||||
|
||||
const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
|
||||
if (result.error) {
|
||||
// 계정 내 도메인 정보 조회 실패 시 WHOIS로 폴백
|
||||
return executeDomainAction('whois', args, allowedDomains, env, telegramUserId, db, userId);
|
||||
}
|
||||
return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user