diff --git a/CLAUDE.md b/CLAUDE.md index 046235e..797b403 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,15 +38,19 @@ Telegram Webhook → Security Validation → Command/Message Router ``` **Core Services**: -- `openai-service.ts` - GPT-4o-mini + Function Calling (6개 도구: weather, search, time, calculate, lookup_docs, manage_domain) +- `openai-service.ts` - GPT-4o-mini + Function Calling (7개 도구: weather, search, time, calculate, lookup_docs, manage_domain, manage_deposit) - `summary-service.ts` - 메시지 버퍼링 + 20개마다 프로필 추출 (슬라이딩 윈도우 3개) - `security.ts` - Webhook 검증 (timing-safe comparison, timestamp validation, rate limiting 30req/min) - `commands.ts` - 봇 명령어 핸들러 (/start, /help, /profile, /reset, /context, /stats, /debug) +- `index.ts` - Email Worker 핸들러 (SMS → 메일 파싱, 은행 알림 자동 매칭) **Data Layer** (D1 SQLite): - `users` - telegram_id 기반 사용자 - `message_buffer` - 롤링 대화 기록 - `summaries` - 프로필 버전 관리 (generation 추적) +- `user_deposits` - 예치금 계정 (잔액 관리) +- `deposit_transactions` - 예치금 거래 내역 (pending/confirmed/rejected) +- `bank_notifications` - 은행 SMS 파싱 결과 (자동 매칭용) **AI Fallback**: OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환 @@ -65,6 +69,7 @@ Telegram Webhook → Security Validation → Command/Message Router - `MAX_SUMMARIES_PER_USER`: 유지할 프로필 버전 수 (기본 3) - `DOMAIN_AGENT_ID`: OpenAI Assistant ID (도메인 관리 에이전트) - `DOMAIN_OWNER_ID`: 도메인 관리 권한 Telegram ID (소유권 검증용) +- `DEPOSIT_ADMIN_ID`: 예치금 관리 권한 Telegram ID (입금 확인/거절) ## External Integrations @@ -74,3 +79,26 @@ Telegram Webhook → Security Validation → Command/Message Router - **wttr.in**: 날씨 API - **DuckDuckGo**: 웹 검색 API - **Vault**: `vault.anvil.it.com`에서 API 키 중앙 관리 +- **Email Workers**: SMS → 메일 수신 → 은행 알림 파싱 (하나/KB/신한 지원) + +## Deposit System + +**예치금 자동 매칭 흐름**: +``` +[사용자 신고] "홍길동 50000원 입금" + ↓ +bank_notifications 테이블 검색 (입금자명 + 금액 매칭) + ↓ +매칭 성공 → confirmed | 매칭 실패 → pending 대기 + +[은행 SMS 수신] Email Worker + ↓ +SMS 파싱 → bank_notifications 저장 + ↓ +deposit_transactions 검색 (pending 상태 + 입금자명 + 금액) + ↓ +매칭 성공 → confirmed + 잔액 증가 | 매칭 실패 → 알림만 저장 +``` + +**입금 계좌**: 하나은행 427-910018-27104 (주식회사 아이언클래드) +- Vault 경로: `secret/companies/ironclad-corp` diff --git a/README.md b/README.md index 9186e9a..c08f504 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ 1. [개요](#개요) 2. [아키텍처](#아키텍처) 3. [Function Calling](#function-calling) -4. [프로젝트 구조](#프로젝트-구조) -5. [배포 가이드](#배포-가이드) -6. [보안 설정](#보안-설정) -7. [봇 명령어](#봇-명령어) +4. [예치금 시스템](#예치금-시스템) +5. [프로젝트 구조](#프로젝트-구조) +6. [배포 가이드](#배포-가이드) +7. [보안 설정](#보안-설정) +8. [봇 명령어](#봇-명령어) --- @@ -20,9 +21,11 @@ - **OpenAI GPT-4o-mini**: 고품질 AI 응답 및 Function Calling 지원 - **사용자 프로필**: 대화에서 사용자의 관심사, 목표, 맥락을 추출하여 프로필 구축 -- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리** 등 AI가 자동으로 도구 호출 +- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리**, **예치금 관리** 등 AI가 자동으로 도구 호출 - **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회 - **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동 +- **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전 +- **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리 - **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억 - **개인화 응답**: 프로필 기반으로 맞춤형 AI 응답 제공 - **폴백 지원**: OpenAI 미설정 시 Workers AI(Llama)로 자동 전환 @@ -37,6 +40,7 @@ | **Context7** | 라이브러리 문서 조회 API | | **Domain Agent** | 도메인 관리 (OpenAI Assistants) | | **Namecheap API** | 도메인 조회/관리 백엔드 | +| **Email Workers** | SMS → 메일 파싱 (입금 알림) | | **Workers AI** | 폴백용 (Llama 3.1 8B) | --- @@ -60,10 +64,11 @@ │ (Function Call) │ 도구 호출 자동 판단 └──────────────────┘ │ - ┌───┴───┬───────┬───────┬───────┬───────┐ - ▼ ▼ ▼ ▼ ▼ ▼ -[날씨] [검색] [시간] [계산] [문서] [도메인] → 외부 API - │ │ │ │ │ │ + ┌───┴───┬───────┬───────┬───────┬───────┬───────┐ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ +[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금] → 외부 API/D1 + │ │ │ │ │ │ │ + │ │ │ │ │ │ └── D1 (자동 매칭) │ │ │ │ │ └── Domain Agent (Assistants API) │ │ │ │ │ ↓ │ │ │ │ └── Context7 API Namecheap API @@ -126,6 +131,7 @@ OpenAI Function Calling을 통해 AI가 자동으로 필요한 도구를 호출 | **계산** | "123 * 456", "100의 20%" | 내장 | | **문서** | "React hooks 사용법", "OpenAI API 예제" | Context7 | | **도메인** | "도메인 목록", "anvil.it.com 네임서버" | Domain Agent (소유자 전용) | +| **예치금** | "잔액 확인", "충전하고 싶어", "10000원 입금했어" | D1 + Email Worker | ### 동작 방식 @@ -147,6 +153,83 @@ OpenAI: 날씨 데이터를 자연어로 응답 생성 --- +## 예치금 시스템 + +은행 계좌 입금 기반 예치금 충전 시스템입니다. 사용자 신고와 은행 SMS 알림을 양방향으로 자동 매칭합니다. + +### 입금 계좌 + +| 은행 | 계좌번호 | 예금주 | +|------|----------|--------| +| 하나은행 | 427-910018-27104 | 주식회사 아이언클래드 | + +> **Vault 경로**: `secret/companies/ironclad-corp` @ `vault.anvil.it.com` + +### 자동 매칭 흐름 + +``` +[시나리오 1: 사용자가 먼저 신고] + +사용자: "홍길동 50000원 입금했어" + │ + ▼ +┌──────────────────┐ +│ bank_notifications│ ← 기존 은행 알림 확인 +└──────────────────┘ + │ + ├── 매칭 발견 → 즉시 confirmed + 잔액 증가 + │ + └── 매칭 없음 → pending 상태로 대기 +``` + +``` +[시나리오 2: 은행 SMS가 먼저 도착] + +은행 SMS → Email Worker 수신 + │ + ▼ +┌──────────────────┐ +│ SMS 파싱 │ ← 입금자명, 금액, 은행 추출 +│ (하나/KB/신한) │ +└──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ deposit_transactions│ ← 대기중 입금 확인 +└──────────────────┘ + │ + ├── 매칭 발견 → 즉시 confirmed + 잔액 증가 + │ + └── 매칭 없음 → bank_notifications에 저장 (나중에 매칭) +``` + +### 지원 기능 + +| 기능 | 설명 | 권한 | +|------|------|------| +| `잔액 조회` | 현재 예치금 잔액 확인 | 모든 사용자 | +| `계좌 안내` | 입금 계좌 정보 표시 | 모든 사용자 | +| `입금 신고` | 입금자명 + 금액으로 충전 요청 | 모든 사용자 | +| `거래 내역` | 최근 거래 내역 조회 | 모든 사용자 | +| `입금 취소` | 대기중 입금 취소 | 모든 사용자 | +| `대기 목록` | 미처리 입금 목록 조회 | 관리자 전용 | +| `수동 확인` | 입금 수동 확정 처리 | 관리자 전용 | +| `입금 거절` | 입금 요청 거절 | 관리자 전용 | + +### Email Worker 설정 + +Cloudflare Email Routing으로 SMS를 메일로 전달받아 파싱합니다. + +1. **Dashboard 설정**: Email > Email Routing > Routes +2. **라우팅**: `deposit@your-domain.com` → Worker: `telegram-summary-bot` + +지원 은행 SMS 패턴: +- 하나은행: `[하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원` +- KB국민: `[KB] 입금 50,000원 01/16 14:30 홍길동` +- 신한: `[신한] 01/16 입금 50,000원 홍길동` + +--- + ## 프로젝트 구조 ``` @@ -194,6 +277,9 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" - `users` - 사용자 정보 - `message_buffer` - 메시지 임시 저장 - `summaries` - 프로필 저장 +- `user_deposits` - 예치금 계정 +- `deposit_transactions` - 예치금 거래 내역 +- `bank_notifications` - 은행 입금 알림 (SMS 파싱) ### 3. Secrets 설정 @@ -305,6 +391,7 @@ MAX_SUMMARIES_PER_USER = "3" N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob" DOMAIN_OWNER_ID = "821596605" +DEPOSIT_ADMIN_ID = "821596605" [[d1_databases]] binding = "DB" diff --git a/schema.sql b/schema.sql index aebbb51..aa4515a 100644 --- a/schema.sql +++ b/schema.sql @@ -45,9 +45,51 @@ CREATE TABLE IF NOT EXISTS user_domains ( FOREIGN KEY (user_id) REFERENCES users(id) ); +-- 예치금 계정 테이블 +CREATE TABLE IF NOT EXISTS user_deposits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + balance INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- 은행 입금 알림 테이블 (SMS → 메일 → 파싱) +CREATE TABLE IF NOT EXISTS bank_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bank_name TEXT, + depositor_name TEXT NOT NULL, + amount INTEGER NOT NULL, + balance_after INTEGER, + transaction_time DATETIME, + raw_message TEXT, + matched_transaction_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (matched_transaction_id) REFERENCES deposit_transactions(id) +); + +-- 예치금 거래 내역 테이블 +CREATE TABLE IF NOT EXISTS deposit_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal', 'refund')), + amount INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'confirmed', 'rejected', 'cancelled')), + depositor_name TEXT, + description TEXT, + confirmed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + -- 인덱스 CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id); CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain); +CREATE INDEX IF NOT EXISTS idx_deposits_user ON user_deposits(user_id); +CREATE INDEX IF NOT EXISTS idx_transactions_user ON deposit_transactions(user_id); +CREATE INDEX IF NOT EXISTS idx_transactions_status ON deposit_transactions(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_bank_notifications_match ON bank_notifications(depositor_name, amount, matched_transaction_id); CREATE INDEX IF NOT EXISTS idx_buffer_user ON message_buffer(user_id); CREATE INDEX IF NOT EXISTS idx_buffer_chat ON message_buffer(user_id, chat_id); CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id); diff --git a/src/index.ts b/src/index.ts index cb63cd5..6ba8c94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Env, TelegramUpdate } from './types'; +import { Env, TelegramUpdate, EmailMessage, BankNotification } from './types'; import { validateWebhookRequest, checkRateLimit } from './security'; import { sendMessage, sendMessageWithKeyboard, setWebhook, getWebhookInfo, sendChatAction } from './telegram'; import { @@ -207,4 +207,195 @@ Documentation: https://github.com/your-repo return new Response('Not Found', { status: 404 }); }, + + // Email 핸들러 (SMS → 메일 → 파싱) + async email(message: EmailMessage, env: Env): Promise { + 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 파싱 실패:', rawEmail.slice(0, 200)); + 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 (env.BOT_TOKEN && env.DEPOSIT_ADMIN_ID) { + const statusMsg = matched + ? `✅ 자동 매칭 완료! (거래 #${matched.transactionId})` + : '⏳ 매칭 대기 중 (사용자 입금 신고 필요)'; + + await sendMessage( + env.BOT_TOKEN, + parseInt(env.DEPOSIT_ADMIN_ID), + `🏦 입금 알림\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); + } + }, }; + +// 은행 SMS 파싱 함수 +function parseBankSMS(content: string): BankNotification | null { + // 이메일에서 SMS 본문 추출 (여러 줄에 걸쳐 있을 수 있음) + const text = content.replace(/\r\n/g, '\n').replace(/=\n/g, ''); + + // 하나은행 패턴: [하나은행] 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 } | null> { + // 매칭 조건: 입금자명 + 금액이 일치하는 pending 거래 + 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 dt.depositor_name = ? + 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 }; +} diff --git a/src/openai-service.ts b/src/openai-service.ts index b3a2cb9..0a3ee42 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -131,6 +131,36 @@ const tools = [ }, }, }, + { + type: 'function', + function: { + name: 'manage_deposit', + description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['balance', 'request_deposit', 'transactions', 'cancel', 'pending_list', 'confirm', 'reject', 'account_info'], + description: '수행할 작업: balance(잔액조회), request_deposit(입금신고/충전요청-입금했다고 말할때도 사용), account_info(입금계좌안내), transactions(거래내역), cancel(입금취소), pending_list(대기목록-관리자), confirm(입금확인-관리자), reject(입금거절-관리자)', + }, + amount: { + type: 'number', + description: '금액 (충전 요청 시 필수)', + }, + depositor_name: { + type: 'string', + description: '입금자명 (충전 요청 시 필수)', + }, + transaction_id: { + type: 'number', + description: '거래 ID (확인/거절/취소 시 필수)', + }, + }, + required: ['action'], + }, + }, + }, ]; // Namecheap API 호출 (allowedDomains로 필터링) @@ -438,6 +468,291 @@ async function executeTool(name: string, args: Record, env?: Env } } + case 'manage_deposit': { + const action = args.action; + const amount = args.amount ? parseInt(args.amount) : 0; + const depositorName = args.depositor_name; + const transactionId = args.transaction_id ? parseInt(args.transaction_id) : 0; + + if (!telegramUserId || !db) { + return '🚫 예치금 기능을 사용할 수 없습니다.'; + } + + const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID; + + // 사용자 조회 또는 생성 + let user = await db.prepare( + 'SELECT id FROM users WHERE telegram_id = ?' + ).bind(telegramUserId).first<{ id: number }>(); + + if (!user) { + return '🚫 사용자 정보를 찾을 수 없습니다.'; + } + + // 예치금 계정 조회 또는 생성 + let deposit = await db.prepare( + 'SELECT id, balance FROM user_deposits WHERE user_id = ?' + ).bind(user.id).first<{ id: number; balance: number }>(); + + if (!deposit) { + await db.prepare( + 'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)' + ).bind(user.id).run(); + deposit = { id: 0, balance: 0 }; + } + + switch (action) { + case 'balance': { + return `💰 현재 예치금 잔액: ${deposit.balance.toLocaleString()}원`; + } + + case 'account_info': { + return `🏦 입금 계좌 안내 + +은행: 하나은행 +계좌: 427-910018-27104 +예금주: 주식회사 아이언클래드 + +입금 후 입금자명과 금액을 알려주세요. +예: "홍길동 10000원 입금했어"`; + } + + case 'request_deposit': { + if (!amount || amount <= 0) { + return '❌ 충전 금액을 입력해주세요. (예: 10000원 충전)'; + } + if (!depositorName) { + return '❌ 입금자명을 입력해주세요. (예: 홍길동 10000원 입금)'; + } + + // 먼저 매칭되는 은행 알림이 있는지 확인 + const bankNotification = await db.prepare( + `SELECT id, amount FROM bank_notifications + WHERE depositor_name = ? AND amount = ? AND matched_transaction_id IS NULL + ORDER BY created_at DESC LIMIT 1` + ).bind(depositorName, amount).first<{ id: number; amount: number }>(); + + if (bankNotification) { + // 은행 알림이 이미 있으면 바로 확정 처리 + const result = await db.prepare( + `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description, confirmed_at) + VALUES (?, 'deposit', ?, 'confirmed', ?, '자동 매칭 확정', CURRENT_TIMESTAMP)` + ).bind(user.id, amount, depositorName).run(); + + const txId = result.meta.last_row_id; + + // 잔액 증가 + 알림 매칭 업데이트 + await db.batch([ + db.prepare( + 'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' + ).bind(amount, user.id), + db.prepare( + 'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?' + ).bind(txId, bankNotification.id), + ]); + + // 업데이트된 잔액 조회 + const newDeposit = await db.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(user.id).first<{ balance: number }>(); + + return `✅ 입금이 확인되었습니다! + +📋 거래 번호: #${txId} +💵 입금액: ${amount.toLocaleString()}원 +👤 입금자: ${depositorName} +💰 현재 잔액: ${newDeposit?.balance.toLocaleString() || 0}원 + +🎉 은행 알림과 자동 매칭되어 즉시 충전되었습니다.`; + } + + // 은행 알림이 없으면 pending 거래 생성 + const result = await db.prepare( + `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description) + VALUES (?, 'deposit', ?, 'pending', ?, '사용자 입금 요청')` + ).bind(user.id, amount, depositorName).run(); + + const txId = result.meta.last_row_id; + + return `✅ 입금 요청이 등록되었습니다. + +📋 요청 번호: #${txId} +💵 금액: ${amount.toLocaleString()}원 +👤 입금자명: ${depositorName} + +🏦 입금 계좌 안내 +은행: 하나은행 +계좌: 427-910018-27104 +예금주: 주식회사 아이언클래드 + +⏳ 은행 입금 확인 후 자동으로 충전됩니다.`; + } + + case 'transactions': { + const transactions = await db.prepare( + `SELECT id, type, amount, status, depositor_name, created_at + FROM deposit_transactions + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 10` + ).bind(user.id).all<{ + id: number; + type: string; + amount: number; + status: string; + depositor_name: string; + created_at: string; + }>(); + + if (!transactions.results?.length) { + return '📜 거래 내역이 없습니다.'; + } + + const statusEmoji: Record = { + pending: '⏳', + confirmed: '✅', + rejected: '❌', + cancelled: '🚫', + }; + const typeLabel: Record = { + deposit: '입금', + withdrawal: '출금', + refund: '환불', + }; + + const lines = transactions.results.map(tx => { + const emoji = statusEmoji[tx.status] || '❓'; + const type = typeLabel[tx.type] || tx.type; + const date = new Date(tx.created_at).toLocaleDateString('ko-KR'); + return `${emoji} #${tx.id} | ${type} ${tx.amount.toLocaleString()}원 | ${date}`; + }); + + return `📜 최근 거래 내역\n\n${lines.join('\n')}`; + } + + case 'cancel': { + if (!transactionId) { + return '❌ 취소할 거래 번호를 입력해주세요.'; + } + + const tx = await db.prepare( + 'SELECT id, status, user_id FROM deposit_transactions WHERE id = ?' + ).bind(transactionId).first<{ id: number; status: string; user_id: number }>(); + + if (!tx) { + return '❌ 거래를 찾을 수 없습니다.'; + } + if (tx.user_id !== user.id && !isAdmin) { + return '🚫 본인의 거래만 취소할 수 있습니다.'; + } + if (tx.status !== 'pending') { + return '❌ 대기 중인 거래만 취소할 수 있습니다.'; + } + + await db.prepare( + "UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?" + ).bind(transactionId).run(); + + return `✅ 거래 #${transactionId}이 취소되었습니다.`; + } + + // 관리자 전용 기능 + case 'pending_list': { + if (!isAdmin) { + return '🚫 관리자 권한이 필요합니다.'; + } + + const pending = await db.prepare( + `SELECT dt.id, dt.amount, dt.depositor_name, dt.created_at, u.telegram_id, u.username + FROM deposit_transactions dt + JOIN users u ON dt.user_id = u.id + WHERE dt.status = 'pending' AND dt.type = 'deposit' + ORDER BY dt.created_at ASC` + ).all<{ + id: number; + amount: number; + depositor_name: string; + created_at: string; + telegram_id: string; + username: string; + }>(); + + if (!pending.results?.length) { + return '✅ 대기 중인 입금 요청이 없습니다.'; + } + + const lines = pending.results.map(p => { + const date = new Date(p.created_at).toLocaleString('ko-KR'); + const user = p.username || p.telegram_id; + return `#${p.id} | ${p.depositor_name} | ${p.amount.toLocaleString()}원 | @${user} | ${date}`; + }); + + return `⏳ 대기 중인 입금 요청\n\n${lines.join('\n')}\n\n입금 확인: "입금 확인 #번호"\n입금 거절: "입금 거절 #번호"`; + } + + case 'confirm': { + if (!isAdmin) { + return '🚫 관리자 권한이 필요합니다.'; + } + if (!transactionId) { + return '❌ 확인할 거래 번호를 입력해주세요.'; + } + + const tx = await db.prepare( + 'SELECT id, user_id, amount, status FROM deposit_transactions WHERE id = ?' + ).bind(transactionId).first<{ id: number; user_id: number; amount: number; status: string }>(); + + if (!tx) { + return '❌ 거래를 찾을 수 없습니다.'; + } + if (tx.status !== 'pending') { + return '❌ 대기 중인 거래만 확인할 수 있습니다.'; + } + + // 트랜잭션: 상태 변경 + 잔액 증가 + await db.batch([ + db.prepare( + "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind(transactionId), + db.prepare( + 'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' + ).bind(tx.amount, tx.user_id), + ]); + + return `✅ 입금 확인 완료!\n\n거래 #${transactionId}\n금액: ${tx.amount.toLocaleString()}원`; + } + + case 'reject': { + if (!isAdmin) { + return '🚫 관리자 권한이 필요합니다.'; + } + if (!transactionId) { + return '❌ 거절할 거래 번호를 입력해주세요.'; + } + + const tx = await db.prepare( + 'SELECT id, status FROM deposit_transactions WHERE id = ?' + ).bind(transactionId).first<{ id: number; status: string }>(); + + if (!tx) { + return '❌ 거래를 찾을 수 없습니다.'; + } + if (tx.status !== 'pending') { + return '❌ 대기 중인 거래만 거절할 수 있습니다.'; + } + + await db.prepare( + "UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?" + ).bind(transactionId).run(); + + return `❌ 입금 거절 완료 (거래 #${transactionId})`; + } + + default: + return '❓ 알 수 없는 작업입니다. 잔액 조회, 충전, 거래 내역 등을 요청해주세요.'; + } + } + case 'manage_domain': { const query = args.query; console.log('[manage_domain] 시작:', { query, telegramUserId, hasDb: !!db }); diff --git a/src/types.ts b/src/types.ts index 130ef18..f37ff1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export interface Env { DOMAIN_AGENT_ID?: string; NAMECHEAP_API_KEY?: string; DOMAIN_OWNER_ID?: string; + DEPOSIT_ADMIN_ID?: string; } export interface IntentAnalysis { @@ -69,3 +70,24 @@ export interface ConversationContext { recentMessages: BufferedMessage[]; totalMessages: number; } + +// Cloudflare Email Workers 타입 +export interface EmailMessage { + from: string; + to: string; + headers: Headers; + raw: ReadableStream; + rawSize: number; + setReject(reason: string): void; + forward(to: string): Promise; +} + +// 은행 알림 파싱 결과 +export interface BankNotification { + bankName: string; + depositorName: string; + amount: number; + balanceAfter?: number; + transactionTime?: Date; + rawMessage: string; +} diff --git a/wrangler.toml b/wrangler.toml index 0cce3f7..7c9ac05 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -11,12 +11,18 @@ MAX_SUMMARIES_PER_USER = "3" N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob" DOMAIN_OWNER_ID = "821596605" +DEPOSIT_ADMIN_ID = "821596605" [[d1_databases]] binding = "DB" database_name = "telegram-conversations" database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" +# Email Worker 설정 (SMS → 메일 수신) +# Cloudflare Dashboard에서 Email Routing 설정 필요: +# 1. Email > Email Routing > Routes +# 2. deposit@your-domain.com → Worker: telegram-summary-bot + # Secrets (wrangler secret put 으로 설정): # - BOT_TOKEN: Telegram Bot Token # - WEBHOOK_SECRET: Webhook 검증용 시크릿