feat: Gmail → Apps Script → Worker 입금 알림 연동
- 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 <noreply@anthropic.com>
This commit is contained in:
41
CLAUDE.md
41
CLAUDE.md
@@ -192,6 +192,7 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
| `index.ts` | Worker 진입점, Email Handler | `fetch()`, `email()` |
|
| `index.ts` | Worker 진입점, Email Handler | `fetch()`, `email()` |
|
||||||
| `openai-service.ts` | AI 응답 + Function Calling | `generateResponse()`, `executeFunctionCall()` |
|
| `openai-service.ts` | AI 응답 + Function Calling | `generateResponse()`, `executeFunctionCall()` |
|
||||||
| `summary-service.ts` | 프로필 시스템 | `updateSummary()`, `getConversationContext()` |
|
| `summary-service.ts` | 프로필 시스템 | `updateSummary()`, `getConversationContext()` |
|
||||||
|
| `deposit-agent.ts` | 예치금 에이전트 (Assistants API) | `callDepositAgent()`, `executeDepositFunction()` |
|
||||||
| `security.ts` | Webhook 보안 | `validateWebhook()`, `checkRateLimit()` |
|
| `security.ts` | Webhook 보안 | `validateWebhook()`, `checkRateLimit()` |
|
||||||
| `commands.ts` | 봇 명령어 | `handleCommand()` |
|
| `commands.ts` | 봇 명령어 | `handleCommand()` |
|
||||||
| `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` |
|
| `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` |
|
||||||
@@ -273,9 +274,11 @@ case 'new_tool':
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `SUMMARY_THRESHOLD` | 20 | 프로필 업데이트 주기 (메시지 수) |
|
| `SUMMARY_THRESHOLD` | 20 | 프로필 업데이트 주기 (메시지 수) |
|
||||||
| `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 |
|
| `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 |
|
||||||
| `DOMAIN_AGENT_ID` | - | OpenAI Assistant ID |
|
| `DOMAIN_AGENT_ID` | - | 도메인 관리 Assistant ID |
|
||||||
| `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID |
|
| `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID |
|
||||||
|
| `DEPOSIT_AGENT_ID` | - | 예치금 관리 Assistant ID |
|
||||||
| `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID |
|
| `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID |
|
||||||
|
| `BANK_API_SECRET` | - | 입금 알림 API 인증 키 (wrangler secret) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -285,11 +288,14 @@ case 'new_tool':
|
|||||||
|--------|------|-----------|----------|
|
|--------|------|-----------|----------|
|
||||||
| Context7 | 문서 조회 | context7.com API | - |
|
| Context7 | 문서 조회 | context7.com API | - |
|
||||||
| Domain Agent | 도메인 관리 | OpenAI Assistants | `asst_MzPFKoqt7V4w6bc0UwcXU4ob` |
|
| Domain Agent | 도메인 관리 | OpenAI Assistants | `asst_MzPFKoqt7V4w6bc0UwcXU4ob` |
|
||||||
|
| Deposit Agent | 예치금 관리 | OpenAI Assistants | `asst_XMoVGU7ZwRpUPI6PHGvRNm8E` |
|
||||||
| Namecheap API | 도메인 백엔드 | namecheap-api.anvil.it.com | 날짜: MM/DD/YYYY → ISO 변환 |
|
| Namecheap API | 도메인 백엔드 | namecheap-api.anvil.it.com | 날짜: MM/DD/YYYY → ISO 변환 |
|
||||||
| WHOIS API | WHOIS 조회 | whois-api-eight.vercel.app | ccSLD 미지원 |
|
| WHOIS API | WHOIS 조회 | whois-api-eight.vercel.app | ccSLD 미지원 |
|
||||||
| wttr.in | 날씨 | wttr.in | - |
|
| wttr.in | 날씨 | wttr.in | - |
|
||||||
| DuckDuckGo | 검색 | api.duckduckgo.com | - |
|
| DuckDuckGo | 검색 | api.duckduckgo.com | - |
|
||||||
| Vault | API 키 관리 | vault.anvil.it.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
|
매칭 O → confirmed | 매칭 X → pending
|
||||||
|
|
||||||
[시나리오 2: SMS 먼저]
|
[시나리오 2: SMS 먼저 - Gmail → Apps Script → Worker]
|
||||||
은행 SMS → Email Worker → 파싱 → bank_notifications 저장
|
은행 SMS → Gmail(deposit.anvil@gmail.com) → Apps Script (1분마다)
|
||||||
↓
|
↓
|
||||||
deposit_transactions 검색 (pending)
|
POST /api/bank-notification
|
||||||
↓
|
↓
|
||||||
매칭 O → confirmed + 잔액↑ | 매칭 X → 저장만
|
파싱 → 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 (주식회사 아이언클래드)
|
**입금 계좌:** 하나은행 427-910018-27104 (주식회사 아이언클래드)
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -25,6 +25,7 @@
|
|||||||
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리**, **예치금 관리** 등 AI가 자동으로 도구 호출
|
- **Function Calling**: 날씨, 검색, 시간, 계산, **문서 조회**, **도메인 관리**, **예치금 관리** 등 AI가 자동으로 도구 호출
|
||||||
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
- **Context7 연동**: 프로그래밍 라이브러리 공식 문서 실시간 조회
|
||||||
- **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동
|
- **Domain Agent**: OpenAI Assistants API 기반 도메인 관리 에이전트 연동
|
||||||
|
- **Deposit Agent**: OpenAI Assistants API 기반 예치금 관리 에이전트 연동
|
||||||
- **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전
|
- **예치금 시스템**: 은행 입금 자동 감지 + 사용자 신고 매칭으로 자동 충전
|
||||||
- **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리
|
- **Email Worker**: SMS → 메일 → 자동 파싱으로 입금 알림 처리
|
||||||
- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억
|
- **무한 컨텍스트**: 슬라이딩 윈도우(3개)로 프로필 유지, 무제한 대화 기억
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
| **OpenAI** | GPT-4o-mini + Function Calling |
|
| **OpenAI** | GPT-4o-mini + Function Calling |
|
||||||
| **Context7** | 라이브러리 문서 조회 API |
|
| **Context7** | 라이브러리 문서 조회 API |
|
||||||
| **Domain Agent** | 도메인 관리 (OpenAI Assistants) |
|
| **Domain Agent** | 도메인 관리 (OpenAI Assistants) |
|
||||||
|
| **Deposit Agent** | 예치금 관리 (OpenAI Assistants) |
|
||||||
| **Namecheap API** | 도메인 조회/관리 백엔드 |
|
| **Namecheap API** | 도메인 조회/관리 백엔드 |
|
||||||
| **Email Workers** | SMS → 메일 파싱 (입금 알림) |
|
| **Email Workers** | SMS → 메일 파싱 (입금 알림) |
|
||||||
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
|
| **Workers AI** | 폴백용 (Llama 3.1 8B) |
|
||||||
@@ -67,12 +69,13 @@
|
|||||||
│
|
│
|
||||||
┌───┴───┬───────┬───────┬───────┬───────┬───────┐
|
┌───┴───┬───────┬───────┬───────┬───────┬───────┐
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금] → 외부 API/D1
|
[날씨] [검색] [시간] [계산] [문서] [도메인] [예치금]
|
||||||
│ │ │ │ │ │ │
|
│ │ │ │ │ │ │
|
||||||
│ │ │ │ │ │ └── D1 (자동 매칭)
|
│ │ │ │ │ │ └── Deposit Agent (Assistants API)
|
||||||
│ │ │ │ │ └── Domain Agent (Assistants API)
|
│ │ │ │ │ │ ↓
|
||||||
│ │ │ │ │ ↓
|
│ │ │ │ │ └── Domain Agent D1 (자동 매칭)
|
||||||
│ │ │ │ └── Context7 API Namecheap API
|
│ │ │ │ │ ↓
|
||||||
|
│ │ │ │ └── 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 패턴:
|
**Apps Script 코드:**
|
||||||
- 하나은행: `[하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원`
|
```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 홍길동`
|
- KB국민: `[KB] 입금 50,000원 01/16 14:30 홍길동`
|
||||||
- 신한: `[신한] 01/16 입금 50,000원 홍길동`
|
- 신한: `[신한] 01/16 입금 50,000원 홍길동`
|
||||||
|
|
||||||
@@ -287,6 +334,7 @@ telegram-bot-workers/
|
|||||||
│ ├── telegram.ts # Telegram API 유틸
|
│ ├── telegram.ts # Telegram API 유틸
|
||||||
│ ├── summary-service.ts # 프로필 분석 서비스
|
│ ├── summary-service.ts # 프로필 분석 서비스
|
||||||
│ ├── openai-service.ts # OpenAI + Function Calling
|
│ ├── openai-service.ts # OpenAI + Function Calling
|
||||||
|
│ ├── deposit-agent.ts # 예치금 에이전트 (Assistants API)
|
||||||
│ ├── n8n-service.ts # n8n 연동 (선택)
|
│ ├── n8n-service.ts # n8n 연동 (선택)
|
||||||
│ └── commands.ts # 봇 명령어 핸들러
|
│ └── commands.ts # 봇 명령어 핸들러
|
||||||
├── schema.sql # D1 스키마
|
├── schema.sql # D1 스키마
|
||||||
@@ -338,6 +386,9 @@ wrangler secret put WEBHOOK_SECRET
|
|||||||
|
|
||||||
# OpenAI API Key (필수)
|
# OpenAI API Key (필수)
|
||||||
wrangler secret put OPENAI_API_KEY
|
wrangler secret put OPENAI_API_KEY
|
||||||
|
|
||||||
|
# 입금 알림 API Secret (Apps Script 연동용)
|
||||||
|
wrangler secret put BANK_API_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vault 연동 (선택)
|
### Vault 연동 (선택)
|
||||||
@@ -437,6 +488,7 @@ MAX_SUMMARIES_PER_USER = "3"
|
|||||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
||||||
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
|
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
|
||||||
DOMAIN_OWNER_ID = "821596605"
|
DOMAIN_OWNER_ID = "821596605"
|
||||||
|
DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E"
|
||||||
DEPOSIT_ADMIN_ID = "821596605"
|
DEPOSIT_ADMIN_ID = "821596605"
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
@@ -470,6 +522,7 @@ database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
|||||||
| `/webhook-info` | GET | Webhook 상태 |
|
| `/webhook-info` | GET | Webhook 상태 |
|
||||||
| `/setup-webhook` | GET | Webhook 설정 |
|
| `/setup-webhook` | GET | Webhook 설정 |
|
||||||
| `/webhook` | POST | Telegram Webhook |
|
| `/webhook` | POST | Telegram Webhook |
|
||||||
|
| `/api/bank-notification` | POST | 입금 알림 API (Apps Script 연동) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
432
src/deposit-agent.ts
Normal file
432
src/deposit-agent.ts
Normal file
@@ -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<string, any>,
|
||||||
|
context: DepositContext
|
||||||
|
): Promise<any> {
|
||||||
|
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<string> {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/index.ts
86
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 처리
|
// Telegram Webhook 처리
|
||||||
if (url.pathname === '/webhook') {
|
if (url.pathname === '/webhook') {
|
||||||
// 보안 검증
|
// 보안 검증
|
||||||
@@ -271,7 +336,26 @@ function parseBankSMS(content: string): BankNotification | null {
|
|||||||
// 이메일에서 SMS 본문 추출 (여러 줄에 걸쳐 있을 수 있음)
|
// 이메일에서 SMS 본문 추출 (여러 줄에 걸쳐 있을 수 있음)
|
||||||
const text = content.replace(/\r\n/g, '\n').replace(/=\n/g, '');
|
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 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);
|
const hanaMatch = text.match(hanaPattern);
|
||||||
if (hanaMatch) {
|
if (hanaMatch) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
import { callDepositAgent } from './deposit-agent';
|
||||||
|
|
||||||
interface OpenAIMessage {
|
interface OpenAIMessage {
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
@@ -135,29 +136,16 @@ const tools = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'manage_deposit',
|
name: 'manage_deposit',
|
||||||
description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.',
|
description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "잔액", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
action: {
|
query: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['balance', 'request_deposit', 'transactions', 'cancel', 'pending_list', 'confirm', 'reject', 'account_info'],
|
description: '예치금 관련 요청 (예: 잔액 확인, 홍길동 10000원 입금, 거래 내역, 입금 취소 #123)',
|
||||||
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'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -561,19 +549,15 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'manage_deposit': {
|
case 'manage_deposit': {
|
||||||
const action = args.action;
|
const query = args.query;
|
||||||
const amount = args.amount ? parseInt(args.amount) : 0;
|
console.log('[manage_deposit] 시작:', { query, telegramUserId, hasDb: !!db });
|
||||||
const depositorName = args.depositor_name;
|
|
||||||
const transactionId = args.transaction_id ? parseInt(args.transaction_id) : 0;
|
|
||||||
|
|
||||||
if (!telegramUserId || !db) {
|
if (!telegramUserId || !db) {
|
||||||
return '🚫 예치금 기능을 사용할 수 없습니다.';
|
return '🚫 예치금 기능을 사용할 수 없습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID;
|
// 사용자 조회
|
||||||
|
const user = await db.prepare(
|
||||||
// 사용자 조회 또는 생성
|
|
||||||
let user = await db.prepare(
|
|
||||||
'SELECT id FROM users WHERE telegram_id = ?'
|
'SELECT id FROM users WHERE telegram_id = ?'
|
||||||
).bind(telegramUserId).first<{ id: number }>();
|
).bind(telegramUserId).first<{ id: number }>();
|
||||||
|
|
||||||
@@ -581,267 +565,37 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
|||||||
return '🚫 사용자 정보를 찾을 수 없습니다.';
|
return '🚫 사용자 정보를 찾을 수 없습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예치금 계정 조회 또는 생성
|
const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID;
|
||||||
let deposit = await db.prepare(
|
|
||||||
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
|
|
||||||
).bind(user.id).first<{ id: number; balance: number }>();
|
|
||||||
|
|
||||||
if (!deposit) {
|
if (!env?.OPENAI_API_KEY || !env?.DEPOSIT_AGENT_ID) {
|
||||||
await db.prepare(
|
console.log('[manage_deposit] DEPOSIT_AGENT_ID 미설정, 기본 응답');
|
||||||
'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
return '💰 예치금 에이전트가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||||
).bind(user.id).run();
|
|
||||||
deposit = { id: 0, balance: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (action) {
|
try {
|
||||||
case 'balance': {
|
console.log('[manage_deposit] callDepositAgent 호출');
|
||||||
return `💰 현재 예치금 잔액: ${deposit.balance.toLocaleString()}원`;
|
const result = await callDepositAgent(
|
||||||
}
|
env.OPENAI_API_KEY,
|
||||||
|
env.DEPOSIT_AGENT_ID,
|
||||||
case 'account_info': {
|
query,
|
||||||
return `🏦 <b>입금 계좌 안내</b>
|
{
|
||||||
|
userId: user.id,
|
||||||
은행: 하나은행
|
telegramUserId,
|
||||||
계좌: 427-910018-27104
|
isAdmin,
|
||||||
예금주: 주식회사 아이언클래드
|
db,
|
||||||
|
|
||||||
입금 후 입금자명과 금액을 알려주세요.
|
|
||||||
예: "홍길동 10000원 입금했어"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'request_deposit': {
|
|
||||||
if (!amount || amount <= 0) {
|
|
||||||
return '❌ 충전 금액을 입력해주세요. (예: 10000원 충전)';
|
|
||||||
}
|
|
||||||
if (!depositorName) {
|
|
||||||
return '❌ 입금자명을 입력해주세요. (예: 홍길동 10000원 입금)';
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
console.log('[manage_deposit] callDepositAgent 완료:', result?.slice(0, 100));
|
||||||
|
|
||||||
// 먼저 매칭되는 은행 알림이 있는지 확인
|
// Markdown → HTML 변환
|
||||||
const bankNotification = await db.prepare(
|
const htmlResult = result
|
||||||
`SELECT id, amount FROM bank_notifications
|
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||||
WHERE depositor_name = ? AND amount = ? AND matched_transaction_id IS NULL
|
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
||||||
ORDER BY created_at DESC LIMIT 1`
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||||
).bind(depositorName, amount).first<{ id: number; amount: number }>();
|
return `💰 ${htmlResult}`;
|
||||||
|
} catch (error) {
|
||||||
if (bankNotification) {
|
console.error('[manage_deposit] 오류:', error);
|
||||||
// 은행 알림이 이미 있으면 바로 확정 처리
|
return `💰 예치금 처리 오류: ${String(error)}`;
|
||||||
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 `✅ 입금이 확인되었습니다!
|
|
||||||
|
|
||||||
📋 <b>거래 번호:</b> #${txId}
|
|
||||||
💵 <b>입금액:</b> ${amount.toLocaleString()}원
|
|
||||||
👤 <b>입금자:</b> ${depositorName}
|
|
||||||
💰 <b>현재 잔액:</b> ${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 `✅ 입금 요청이 등록되었습니다.
|
|
||||||
|
|
||||||
📋 <b>요청 번호:</b> #${txId}
|
|
||||||
💵 <b>금액:</b> ${amount.toLocaleString()}원
|
|
||||||
👤 <b>입금자명:</b> ${depositorName}
|
|
||||||
|
|
||||||
🏦 <b>입금 계좌 안내</b>
|
|
||||||
은행: 하나은행
|
|
||||||
계좌: 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<string, string> = {
|
|
||||||
pending: '⏳',
|
|
||||||
confirmed: '✅',
|
|
||||||
rejected: '❌',
|
|
||||||
cancelled: '🚫',
|
|
||||||
};
|
|
||||||
const typeLabel: Record<string, string> = {
|
|
||||||
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 `📜 <b>최근 거래 내역</b>\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 `⏳ <b>대기 중인 입금 요청</b>\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 '❓ 알 수 없는 작업입니다. 잔액 조회, 충전, 거래 내역 등을 요청해주세요.';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Env {
|
|||||||
DOMAIN_AGENT_ID?: string;
|
DOMAIN_AGENT_ID?: string;
|
||||||
NAMECHEAP_API_KEY?: string;
|
NAMECHEAP_API_KEY?: string;
|
||||||
DOMAIN_OWNER_ID?: string;
|
DOMAIN_OWNER_ID?: string;
|
||||||
|
DEPOSIT_AGENT_ID?: string;
|
||||||
DEPOSIT_ADMIN_ID?: string;
|
DEPOSIT_ADMIN_ID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ MAX_SUMMARIES_PER_USER = "3"
|
|||||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com"
|
||||||
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
|
DOMAIN_AGENT_ID = "asst_MzPFKoqt7V4w6bc0UwcXU4ob"
|
||||||
DOMAIN_OWNER_ID = "821596605"
|
DOMAIN_OWNER_ID = "821596605"
|
||||||
|
DEPOSIT_AGENT_ID = "asst_XMoVGU7ZwRpUPI6PHGvRNm8E"
|
||||||
DEPOSIT_ADMIN_ID = "821596605"
|
DEPOSIT_ADMIN_ID = "821596605"
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
|
|||||||
Reference in New Issue
Block a user