Files
telegram-bot-workers/src/deposit-agent.ts
kappa db859efc56 feat: 도메인 인라인 버튼 등록 + cheapest TLD + Cron 자동취소
- 도메인 등록 인라인 버튼 확인 플로우 (domain-register.ts)
- manage_domain에 cheapest action 추가 (가장 저렴한 TLD TOP 15)
- 24시간 경과 입금 대기 자동 취소 Cron (UTC 15:00)
- 거래 내역 한글 라벨 + description 표시
- CLAUDE.md 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 15:24:03 +09:00

438 lines
14 KiB
TypeScript

/**
* Deposit Agent - 예치금 관리 (코드 직접 처리)
*
* 변경 이력:
* - 2026-01: Assistants API → 코드 직접 처리로 변경 (지역 제한 우회, 응답 일관성)
*
* 기능:
* - 잔액 조회
* - 입금 신고 (자동 매칭)
* - 거래 내역 조회
* - 입금 취소
* - [관리자] 대기 목록, 입금 확인/거절
*/
export interface DepositContext {
userId: number;
telegramUserId: string;
isAdmin: boolean;
db: D1Database;
}
// 예치금 API 함수 실행 (export for direct use without Agent)
export 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, description, 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;
description: string | null;
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,
description: tx.description,
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)}`;
}
}