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:
kappa
2026-01-17 00:09:16 +09:00
parent cd33a7c790
commit 9822b28028
7 changed files with 652 additions and 300 deletions

View File

@@ -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,14 +308,35 @@ 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분마다)
POST /api/bank-notification
파싱 → bank_notifications 저장
deposit_transactions 검색 (pending) deposit_transactions 검색 (pending)
매칭 O → confirmed + 잔액↑ | 매칭 X → 저장만 매칭 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 (주식회사 아이언클래드)
- Vault 경로: `secret/companies/ironclad-corp` - Vault 경로: `secret/companies/ironclad-corp`

View File

@@ -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
View 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)}`;
}
}

View File

@@ -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) {

View File

@@ -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,
query,
{
userId: user.id,
telegramUserId,
isAdmin,
db,
} }
);
console.log('[manage_deposit] callDepositAgent 완료:', result?.slice(0, 100));
case 'account_info': { // Markdown → HTML 변환
return `🏦 <b>입금 계좌 안내</b> const htmlResult = result
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
은행: 하나은행 .replace(/\*(.+?)\*/g, '<i>$1</i>')
계좌: 427-910018-27104 .replace(/`(.+?)`/g, '<code>$1</code>');
예금주: 주식회사 아이언클래드 return `💰 ${htmlResult}`;
} catch (error) {
입금 후 입금자명과 금액을 알려주세요. console.error('[manage_deposit] 오류:', error);
예: "홍길동 10000원 입금했어"`; return `💰 예치금 처리 오류: ${String(error)}`;
}
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 `✅ 입금이 확인되었습니다!
📋 <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 '❓ 알 수 없는 작업입니다. 잔액 조회, 충전, 거래 내역 등을 요청해주세요.';
} }
} }

View File

@@ -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;
} }

View File

@@ -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]]