/** * 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)}`; } }