From 9822b28028da16a225ea01c09464d98dcc7e4e64 Mon Sep 17 00:00:00 2001 From: kappa Date: Sat, 17 Jan 2026 00:09:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Gmail=20=E2=86=92=20Apps=20Script=20?= =?UTF-8?q?=E2=86=92=20Worker=20=EC=9E=85=EA=B8=88=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/bank-notification 엔드포인트 추가 - 하나은행 Web발신 SMS 패턴 파싱 지원 - Gmail message_id 기반 중복 방지 - BANK_API_SECRET 인증 추가 - deposit_transactions 자동 매칭 구현 - 문서 업데이트 (CLAUDE.md, README.md) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 41 +++- README.md | 75 ++++++-- src/deposit-agent.ts | 432 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 86 ++++++++- src/openai-service.ts | 316 ++++-------------------------- src/types.ts | 1 + wrangler.toml | 1 + 7 files changed, 652 insertions(+), 300 deletions(-) create mode 100644 src/deposit-agent.ts diff --git a/CLAUDE.md b/CLAUDE.md index 955228b..abaa2cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,7 @@ Telegram Webhook → Security Validation → Command/Message Router | `index.ts` | Worker 진입점, Email Handler | `fetch()`, `email()` | | `openai-service.ts` | AI 응답 + Function Calling | `generateResponse()`, `executeFunctionCall()` | | `summary-service.ts` | 프로필 시스템 | `updateSummary()`, `getConversationContext()` | +| `deposit-agent.ts` | 예치금 에이전트 (Assistants API) | `callDepositAgent()`, `executeDepositFunction()` | | `security.ts` | Webhook 보안 | `validateWebhook()`, `checkRateLimit()` | | `commands.ts` | 봇 명령어 | `handleCommand()` | | `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` | @@ -273,9 +274,11 @@ case 'new_tool': |------|--------|------| | `SUMMARY_THRESHOLD` | 20 | 프로필 업데이트 주기 (메시지 수) | | `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 | -| `DOMAIN_AGENT_ID` | - | OpenAI Assistant ID | +| `DOMAIN_AGENT_ID` | - | 도메인 관리 Assistant ID | | `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID | +| `DEPOSIT_AGENT_ID` | - | 예치금 관리 Assistant ID | | `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID | +| `BANK_API_SECRET` | - | 입금 알림 API 인증 키 (wrangler secret) | --- @@ -285,11 +288,14 @@ case 'new_tool': |--------|------|-----------|----------| | Context7 | 문서 조회 | context7.com API | - | | Domain Agent | 도메인 관리 | OpenAI Assistants | `asst_MzPFKoqt7V4w6bc0UwcXU4ob` | +| Deposit Agent | 예치금 관리 | OpenAI Assistants | `asst_XMoVGU7ZwRpUPI6PHGvRNm8E` | | Namecheap API | 도메인 백엔드 | namecheap-api.anvil.it.com | 날짜: MM/DD/YYYY → ISO 변환 | | WHOIS API | WHOIS 조회 | whois-api-eight.vercel.app | ccSLD 미지원 | | wttr.in | 날씨 | wttr.in | - | | DuckDuckGo | 검색 | api.duckduckgo.com | - | | Vault | API 키 관리 | vault.anvil.it.com | - | +| Gmail | 입금 SMS 수신 | deposit.anvil@gmail.com | Apps Script 연동 | +| Apps Script | Gmail → Worker 연동 | script.google.com | 1분마다 실행, message_id 중복 방지 | --- @@ -302,12 +308,33 @@ case 'new_tool': ↓ 매칭 O → confirmed | 매칭 X → pending -[시나리오 2: SMS 먼저] -은행 SMS → Email Worker → 파싱 → bank_notifications 저장 - ↓ - deposit_transactions 검색 (pending) - ↓ - 매칭 O → confirmed + 잔액↑ | 매칭 X → 저장만 +[시나리오 2: SMS 먼저 - Gmail → Apps Script → Worker] +은행 SMS → Gmail(deposit.anvil@gmail.com) → Apps Script (1분마다) + ↓ + POST /api/bank-notification + ↓ + 파싱 → bank_notifications 저장 + ↓ + deposit_transactions 검색 (pending) + ↓ + 매칭 O → confirmed + 잔액↑ | 매칭 X → 저장만 +``` + +**Gmail → Worker 연동:** +- Gmail 계정: `deposit.anvil@gmail.com` +- Apps Script: 1분마다 `is:unread 입금` 검색 → Worker API 호출 +- 중복 방지: Gmail message_id 기반 + +**API 엔드포인트:** +``` +POST /api/bank-notification +Content-Type: application/json + +{ + "content": "[Web발신]\n하나,01/16, 23:30\n427******27104\n입금5원\n황병하", + "messageId": "19bc737b3415596a", + "secret": "BANK_API_SECRET 값" +} ``` **입금 계좌:** 하나은행 427-910018-27104 (주식회사 아이언클래드) diff --git a/README.md b/README.md index 17cd7d8..c7354b6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리**, **예치금 관리** 등 AI가 자동으로 도구 호출 - **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회 - **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동 +- **Deposit Agent**: OpenAI Assistants API 기반 예치금 관리 에이전트 연동 - **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전 - **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리 - **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억 @@ -40,6 +41,7 @@ | **OpenAI** | GPT-4o-mini + Function Calling | | **Context7** | 라이브러리 문서 조회 API | | **Domain Agent** | 도메인 관리 (OpenAI Assistants) | +| **Deposit Agent** | 예치금 관리 (OpenAI Assistants) | | **Namecheap API** | 도메인 조회/관리 백엔드 | | **Email Workers** | SMS → 메일 파싱 (입금 알림) | | **Workers AI** | 폴백용 (Llama 3.1 8B) | @@ -67,12 +69,13 @@ │ ┌───┴───┬───────┬───────┬───────┬───────┬───────┐ ▼ ▼ ▼ ▼ ▼ ▼ ▼ -[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금] → 외부 API/D1 +[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금] │ │ │ │ │ │ │ - │ │ │ │ │ │ └── D1 (자동 매칭) - │ │ │ │ │ └── Domain Agent (Assistants API) - │ │ │ │ │ ↓ - │ │ │ │ └── Context7 API Namecheap API + │ │ │ │ │ │ └── Deposit Agent (Assistants API) + │ │ │ │ │ │ ↓ + │ │ │ │ │ └── Domain Agent D1 (자동 매칭) + │ │ │ │ │ ↓ + │ │ │ │ └── Context7 Namecheap API └───┬───┴───────┴───────┴───────┴───────────────────┘ ▼ ┌──────────────────┐ @@ -217,15 +220,59 @@ OpenAI: 날씨 데이터를 자연어로 응답 생성 | `수동 확인` | 입금 수동 확정 처리 | 관리자 전용 | | `입금 거절` | 입금 요청 거절 | 관리자 전용 | -### Email Worker 설정 +### Gmail → Apps Script → Worker 연동 -Cloudflare Email Routing으로 SMS를 메일로 전달받아 파싱합니다. +SMS를 Gmail로 전달받아 Apps Script에서 Worker API를 호출합니다. -1. **Dashboard 설정**: Email > Email Routing > Routes -2. **라우팅**: `deposit@your-domain.com` → Worker: `telegram-summary-bot` +**흐름:** +``` +은행 SMS → Gmail(deposit.anvil@gmail.com) → Apps Script (1분마다) + ↓ + POST /api/bank-notification → DB 저장 → 자동 매칭 +``` -지원 은행 SMS 패턴: -- 하나은행: `[하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원` +**Apps Script 코드:** +```javascript +function checkBankEmails() { + var threads = GmailApp.search('is:unread 입금', 0, 10); + + for (var i = 0; i < threads.length; i++) { + var messages = threads[i].getMessages(); + + for (var j = 0; j < messages.length; j++) { + var message = messages[j]; + if (!message.isUnread()) continue; + + var messageId = message.getId(); + var body = message.getPlainBody(); + + try { + UrlFetchApp.fetch( + 'https://telegram-summary-bot.kappa-d8e.workers.dev/api/bank-notification', + { + method: 'POST', + contentType: 'application/json', + payload: JSON.stringify({ + content: body, + messageId: messageId, + secret: 'BANK_API_SECRET 값' + }) + } + ); + } catch (e) { + console.log('Error: ' + e); + } + } + threads[i].markRead(); + } +} +``` + +**트리거 설정:** 시간 기반 → 분 타이머 → 1분마다 + +**지원 은행 SMS 패턴:** +- 하나은행 (Web발신): `[Web발신] 하나,01/16, 23:30 427******27104 입금5원 황병하` +- 하나은행 (기존): `[하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원` - KB국민: `[KB] 입금 50,000원 01/16 14:30 홍길동` - 신한: `[신한] 01/16 입금 50,000원 홍길동` @@ -287,6 +334,7 @@ telegram-bot-workers/ │ ├── telegram.ts # Telegram API 유틸 │ ├── summary-service.ts # 프로필 분석 서비스 │ ├── openai-service.ts # OpenAI + Function Calling +│ ├── deposit-agent.ts # 예치금 에이전트 (Assistants API) │ ├── n8n-service.ts # n8n 연동 (선택) │ └── commands.ts # 봇 명령어 핸들러 ├── schema.sql # D1 스키마 @@ -338,6 +386,9 @@ wrangler secret put WEBHOOK_SECRET # OpenAI API Key (필수) wrangler secret put OPENAI_API_KEY + +# 입금 알림 API Secret (Apps Script 연동용) +wrangler secret put BANK_API_SECRET ``` ### Vault 연동 (선택) @@ -437,6 +488,7 @@ MAX_SUMMARIES_PER_USER = "3" N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob" DOMAIN_OWNER_ID = "821596605" +DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E" DEPOSIT_ADMIN_ID = "821596605" [[d1_databases]] @@ -470,6 +522,7 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" | `/webhook-info` | GET | Webhook 상태 | | `/setup-webhook` | GET | Webhook 설정 | | `/webhook` | POST | Telegram Webhook | +| `/api/bank-notification` | POST | 입금 알림 API (Apps Script 연동) | --- diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts new file mode 100644 index 0000000..11c3c81 --- /dev/null +++ b/src/deposit-agent.ts @@ -0,0 +1,432 @@ +/** + * Deposit Agent - 예치금 관리 에이전트 (OpenAI Assistants API) + * + * 기능: + * - 잔액 조회 + * - 입금 신고 (자동 매칭) + * - 거래 내역 조회 + * - 입금 취소 + * - [관리자] 대기 목록, 입금 확인/거절 + */ + +interface DepositContext { + userId: number; + telegramUserId: string; + isAdmin: boolean; + db: D1Database; +} + +// 예치금 API 함수 실행 +async function executeDepositFunction( + funcName: string, + funcArgs: Record, + context: DepositContext +): Promise { + const { userId, isAdmin, db } = context; + + // 예치금 계정 조회 또는 생성 + let deposit = await db.prepare( + 'SELECT id, balance FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ id: number; balance: number }>(); + + if (!deposit) { + await db.prepare( + 'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)' + ).bind(userId).run(); + deposit = { id: 0, balance: 0 }; + } + + switch (funcName) { + case 'get_balance': { + return { + balance: deposit.balance, + formatted: `${deposit.balance.toLocaleString()}원`, + }; + } + + case 'get_account_info': { + return { + bank: '하나은행', + account: '427-910018-27104', + holder: '주식회사 아이언클래드', + instruction: '입금 후 입금자명과 금액을 알려주세요.', + }; + } + + case 'request_deposit': { + const { depositor_name, amount } = funcArgs; + + if (!amount || amount <= 0) { + return { error: '충전 금액을 입력해주세요.' }; + } + if (!depositor_name) { + return { error: '입금자명을 입력해주세요.' }; + } + + // 먼저 매칭되는 은행 알림이 있는지 확인 + 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(depositor_name, 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(userId, amount, depositor_name).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, userId), + 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(userId).first<{ balance: number }>(); + + return { + success: true, + auto_matched: true, + transaction_id: txId, + amount: amount, + depositor_name: depositor_name, + new_balance: newDeposit?.balance || 0, + message: '은행 알림과 자동 매칭되어 즉시 충전되었습니다.', + }; + } + + // 은행 알림이 없으면 pending 거래 생성 + const result = await db.prepare( + `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description) + VALUES (?, 'deposit', ?, 'pending', ?, '사용자 입금 요청')` + ).bind(userId, amount, depositor_name).run(); + + return { + success: true, + auto_matched: false, + transaction_id: result.meta.last_row_id, + amount: amount, + depositor_name: depositor_name, + status: 'pending', + message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.', + account_info: { + bank: '하나은행', + account: '427-910018-27104', + holder: '주식회사 아이언클래드', + }, + }; + } + + case 'get_transactions': { + const limit = funcArgs.limit || 10; + + const transactions = await db.prepare( + `SELECT id, type, amount, status, depositor_name, created_at, confirmed_at + FROM deposit_transactions + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ?` + ).bind(userId, limit).all<{ + id: number; + type: string; + amount: number; + status: string; + depositor_name: string; + created_at: string; + confirmed_at: string | null; + }>(); + + if (!transactions.results?.length) { + return { transactions: [], message: '거래 내역이 없습니다.' }; + } + + return { + transactions: transactions.results.map(tx => ({ + id: tx.id, + type: tx.type, + amount: tx.amount, + status: tx.status, + depositor_name: tx.depositor_name, + created_at: tx.created_at, + confirmed_at: tx.confirmed_at, + })), + }; + } + + case 'cancel_transaction': { + const { transaction_id } = funcArgs; + + if (!transaction_id) { + return { error: '취소할 거래 번호를 입력해주세요.' }; + } + + const tx = await db.prepare( + 'SELECT id, status, user_id FROM deposit_transactions WHERE id = ?' + ).bind(transaction_id).first<{ id: number; status: string; user_id: number }>(); + + if (!tx) { + return { error: '거래를 찾을 수 없습니다.' }; + } + if (tx.user_id !== userId && !isAdmin) { + return { error: '본인의 거래만 취소할 수 있습니다.' }; + } + if (tx.status !== 'pending') { + return { error: '대기 중인 거래만 취소할 수 있습니다.' }; + } + + await db.prepare( + "UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?" + ).bind(transaction_id).run(); + + return { + success: true, + transaction_id: transaction_id, + message: '거래가 취소되었습니다.', + }; + } + + // 관리자 전용 기능 + case 'get_pending_list': { + if (!isAdmin) { + return { error: '관리자 권한이 필요합니다.' }; + } + + 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 { pending: [], message: '대기 중인 입금 요청이 없습니다.' }; + } + + return { + pending: pending.results.map(p => ({ + id: p.id, + amount: p.amount, + depositor_name: p.depositor_name, + created_at: p.created_at, + user: p.username || p.telegram_id, + })), + }; + } + + case 'confirm_deposit': { + if (!isAdmin) { + return { error: '관리자 권한이 필요합니다.' }; + } + + const { transaction_id } = funcArgs; + if (!transaction_id) { + return { error: '확인할 거래 번호를 입력해주세요.' }; + } + + const tx = await db.prepare( + 'SELECT id, user_id, amount, status FROM deposit_transactions WHERE id = ?' + ).bind(transaction_id).first<{ id: number; user_id: number; amount: number; status: string }>(); + + if (!tx) { + return { error: '거래를 찾을 수 없습니다.' }; + } + if (tx.status !== 'pending') { + return { error: '대기 중인 거래만 확인할 수 있습니다.' }; + } + + // 트랜잭션: 상태 변경 + 잔액 증가 + await db.batch([ + db.prepare( + "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind(transaction_id), + db.prepare( + 'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' + ).bind(tx.amount, tx.user_id), + ]); + + return { + success: true, + transaction_id: transaction_id, + amount: tx.amount, + message: '입금이 확인되었습니다.', + }; + } + + case 'reject_deposit': { + if (!isAdmin) { + return { error: '관리자 권한이 필요합니다.' }; + } + + const { transaction_id } = funcArgs; + if (!transaction_id) { + return { error: '거절할 거래 번호를 입력해주세요.' }; + } + + const tx = await db.prepare( + 'SELECT id, status FROM deposit_transactions WHERE id = ?' + ).bind(transaction_id).first<{ id: number; status: string }>(); + + if (!tx) { + return { error: '거래를 찾을 수 없습니다.' }; + } + if (tx.status !== 'pending') { + return { error: '대기 중인 거래만 거절할 수 있습니다.' }; + } + + await db.prepare( + "UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?" + ).bind(transaction_id).run(); + + return { + success: true, + transaction_id: transaction_id, + message: '입금 요청이 거절되었습니다.', + }; + } + + default: + return { error: `알 수 없는 함수: ${funcName}` }; + } +} + +// Deposit Agent 호출 (Assistants API) +export async function callDepositAgent( + apiKey: string, + assistantId: string, + query: string, + context: DepositContext +): Promise { + try { + // 1. Thread 생성 + const threadRes = await fetch('https://api.openai.com/v1/threads', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Beta': 'assistants=v2', + }, + body: JSON.stringify({}), + }); + if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`; + const thread = await threadRes.json() as { id: string }; + + // 2. 메시지 추가 (권한 정보 포함) + const adminInfo = context.isAdmin ? '관리자 권한이 있습니다.' : '일반 사용자입니다.'; + const instructions = `[시스템 정보] +- ${adminInfo} +- 사용자 ID: ${context.telegramUserId} + +[사용자 요청] +${query}`; + + await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Beta': 'assistants=v2', + }, + body: JSON.stringify({ + role: 'user', + content: instructions, + }), + }); + + // 3. Run 생성 + const runRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Beta': 'assistants=v2', + }, + body: JSON.stringify({ assistant_id: assistantId }), + }); + if (!runRes.ok) return `Run 생성 실패 (${runRes.status})`; + let run = await runRes.json() as { id: string; status: string; required_action?: any }; + + // 4. 완료까지 폴링 및 Function Calling 처리 + let maxPolls = 30; // 최대 15초 + while ((run.status === 'queued' || run.status === 'in_progress' || run.status === 'requires_action') && maxPolls > 0) { + if (run.status === 'requires_action') { + const toolCalls = run.required_action?.submit_tool_outputs?.tool_calls || []; + const toolOutputs = []; + + for (const toolCall of toolCalls) { + const funcName = toolCall.function.name; + const funcArgs = JSON.parse(toolCall.function.arguments); + console.log(`[DepositAgent] Function call: ${funcName}`, funcArgs); + + const result = await executeDepositFunction(funcName, funcArgs, context); + toolOutputs.push({ + tool_call_id: toolCall.id, + output: JSON.stringify(result), + }); + } + + // Tool outputs 제출 + const submitRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}/submit_tool_outputs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Beta': 'assistants=v2', + }, + body: JSON.stringify({ tool_outputs: toolOutputs }), + }); + run = await submitRes.json() as { id: string; status: string; required_action?: any }; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + maxPolls--; + + const statusRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Beta': 'assistants=v2', + }, + }); + run = await statusRes.json() as { id: string; status: string; required_action?: any }; + } + + if (run.status === 'failed') return '예치금 에이전트 실행 실패'; + if (maxPolls === 0) return '응답 시간 초과. 다시 시도해주세요.'; + + // 5. 메시지 조회 + const messagesRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Beta': 'assistants=v2', + }, + }); + const messages = await messagesRes.json() as { data: Array<{ role: string; content: Array<{ type: string; text?: { value: string } }> }> }; + const lastMessage = messages.data[0]; + + if (lastMessage?.content?.[0]?.type === 'text') { + return lastMessage.content[0].text?.value || '응답 없음'; + } + + return '예치금 에이전트 응답 없음'; + } catch (error) { + console.error('[DepositAgent] Error:', error); + return `예치금 에이전트 오류: ${String(error)}`; + } +} diff --git a/src/index.ts b/src/index.ts index 6ba8c94..48241af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,6 +169,71 @@ 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); + + 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 }); + } + } + // Telegram Webhook 처리 if (url.pathname === '/webhook') { // 보안 검증 @@ -271,7 +336,26 @@ 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원 + // 하나은행 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) { diff --git a/src/openai-service.ts b/src/openai-service.ts index d7395f9..9674c1a 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -1,4 +1,5 @@ import type { Env } from './types'; +import { callDepositAgent } from './deposit-agent'; interface OpenAIMessage { role: 'system' | 'user' | 'assistant' | 'tool'; @@ -135,29 +136,16 @@ const tools = [ type: 'function', function: { name: 'manage_deposit', - description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.', + description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "잔액", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.', parameters: { type: 'object', properties: { - action: { + query: { 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 (확인/거절/취소 시 필수)', + description: '예치금 관련 요청 (예: 잔액 확인, 홍길동 10000원 입금, 거래 내역, 입금 취소 #123)', }, }, - required: ['action'], + required: ['query'], }, }, }, @@ -561,19 +549,15 @@ 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; + const query = args.query; + console.log('[manage_deposit] 시작:', { query, telegramUserId, hasDb: !!db }); if (!telegramUserId || !db) { return '🚫 예치금 기능을 사용할 수 없습니다.'; } - const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID; - - // 사용자 조회 또는 생성 - let user = await db.prepare( + // 사용자 조회 + const user = await db.prepare( 'SELECT id FROM users WHERE telegram_id = ?' ).bind(telegramUserId).first<{ id: number }>(); @@ -581,267 +565,37 @@ async function executeTool(name: string, args: Record, env?: Env return '🚫 사용자 정보를 찾을 수 없습니다.'; } - // 예치금 계정 조회 또는 생성 - let deposit = await db.prepare( - 'SELECT id, balance FROM user_deposits WHERE user_id = ?' - ).bind(user.id).first<{ id: number; balance: number }>(); + const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID; - if (!deposit) { - await db.prepare( - 'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)' - ).bind(user.id).run(); - deposit = { id: 0, balance: 0 }; + if (!env?.OPENAI_API_KEY || !env?.DEPOSIT_AGENT_ID) { + console.log('[manage_deposit] DEPOSIT_AGENT_ID 미설정, 기본 응답'); + return '💰 예치금 에이전트가 설정되지 않았습니다. 관리자에게 문의하세요.'; } - 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원 입금)'; + try { + console.log('[manage_deposit] callDepositAgent 호출'); + const result = await callDepositAgent( + env.OPENAI_API_KEY, + env.DEPOSIT_AGENT_ID, + query, + { + userId: user.id, + telegramUserId, + isAdmin, + db, } + ); + console.log('[manage_deposit] callDepositAgent 완료:', result?.slice(0, 100)); - // 먼저 매칭되는 은행 알림이 있는지 확인 - 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 '❓ 알 수 없는 작업입니다. 잔액 조회, 충전, 거래 내역 등을 요청해주세요.'; + // Markdown → HTML 변환 + const htmlResult = result + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1'); + return `💰 ${htmlResult}`; + } catch (error) { + console.error('[manage_deposit] 오류:', error); + return `💰 예치금 처리 오류: ${String(error)}`; } } diff --git a/src/types.ts b/src/types.ts index f37ff1e..96dbf3f 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_AGENT_ID?: string; DEPOSIT_ADMIN_ID?: string; } diff --git a/wrangler.toml b/wrangler.toml index 7c9ac05..808e5ea 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -11,6 +11,7 @@ MAX_SUMMARIES_PER_USER = "3" N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob" DOMAIN_OWNER_ID = "821596605" +DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E" DEPOSIT_ADMIN_ID = "821596605" [[d1_databases]]