Initial implementation of Telegram AI customer support bot

Cloudflare Workers + Hono + D1 + KV + R2 stack with 4 specialized AI agents
(onboarding, troubleshoot, asset, billing), OpenAI function calling with
7 tool definitions, human escalation, pending action approval workflow,
feedback collection, audit logging, i18n (ko/en), and Workers AI fallback.

43 source files, 45 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-11 13:21:38 +09:00
commit 1d6b64c9e4
58 changed files with 12857 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
/**
* Agent Registry - 데이터 기반 에이전트 라우팅
*
* 등록된 에이전트를 순회하며 활성 세션이 있는 에이전트로 라우팅합니다.
* PASSTHROUGH 응답 시 다음 에이전트를 확인하고,
* 에러 시 해당 에이전트를 건너뛰고 계속 진행합니다.
*/
import type { Env } from '../types';
import { createLogger } from '../utils/logger';
const logger = createLogger('agent-registry');
export interface RegisterableAgent {
hasSession(db: D1Database, userId: string): Promise<boolean>;
processConsultation(
db: D1Database, userId: string, userMessage: string, env: Env
): Promise<string>;
}
interface AgentEntry {
name: string;
agent: RegisterableAgent;
priority: number;
}
const registry: AgentEntry[] = [];
/**
* 에이전트를 레지스트리에 등록합니다.
* priority가 낮을수록 먼저 확인됩니다.
*/
export function registerAgent(
name: string,
agent: RegisterableAgent,
priority: number = 0
): void {
registry.push({ name, agent, priority });
registry.sort((a, b) => a.priority - b.priority);
}
/**
* 활성 세션이 있는 에이전트를 찾아 메시지를 라우팅합니다.
*
* @returns 에이전트 응답 또는 null (세션 없음)
*/
export async function routeToActiveAgent(
db: D1Database,
userId: string,
userMessage: string,
env: Env
): Promise<string | null> {
for (const entry of registry) {
try {
const hasSession = await entry.agent.hasSession(db, userId);
if (!hasSession) continue;
logger.info('세션 감지, 에이전트로 라우팅', {
agent: entry.name,
userId,
});
const response = await entry.agent.processConsultation(
db, userId, userMessage, env
);
// PASSTHROUGH: 무관한 메시지, 다음 에이전트 확인
if (response === '__PASSTHROUGH__') {
continue;
}
return response;
} catch (error) {
logger.error('에이전트 라우팅 실패, 다음 에이전트 확인', error as Error, {
agent: entry.name,
userId,
});
continue;
}
}
return null;
}

408
src/agents/asset-agent.ts Normal file
View File

@@ -0,0 +1,408 @@
/**
* Asset Agent - 자산 관리/대시보드 에이전트
*
* 기존 고객의 자산 현황을 조회하고 관리합니다:
* - 잔액, 도메인, 서버, DDoS, VPN 서비스 종합 대시보드
* - 개별 자산 상세 조회 및 관리
* - single-shot 전략: AI가 도구 호출 결정 -> 결과 조합
*/
import type { ToolDefinition, AssetSession, ManageDomainArgs, ManageServerArgs, CheckServiceArgs } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('asset');
const logger = createLogger('asset-agent');
class AssetSessionManager extends SessionManager<AssetSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
}
export class AssetAgent extends BaseAgent<AssetSession> {
protected readonly agentName = 'asset-agent';
protected readonly sessionManager = new AssetSessionManager();
protected getExecutionStrategy() { return 'single-shot' as const; }
protected getInitialStatus() { return 'idle'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: AssetSession): string {
return `당신은 호스팅/인프라 서비스의 자산 관리 도우미입니다.
고객이 보유한 자산(잔액, 도메인, 서버, DDoS, VPN)을 조회하고 관리할 수 있도록 도와줍니다.
## 주요 기능
1. **대시보드**: 전체 자산 요약 조회 (잔액, 서버 수, 도메인 수, 서비스 현황)
2. **도메인 관리**: 도메인 목록, 상세 정보, 네임서버 설정
3. **서버 관리**: 서버 목록, 상태 확인, 시작/중지/재부팅
4. **서비스 조회**: DDoS/VPN 서비스 상태 확인
## 현재 세션 상태: ${session.status}
## 응답 원칙
- 항상 한국어로 응답하세요.
- 자산 정보는 보기 쉽게 정리해서 보여주세요.
- 금액은 원(KRW) 단위로, 천 단위 구분자를 사용하세요.
- 자산 관리와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 조회가 완료되고 추가 요청이 없으면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- get_dashboard: 전체 자산 요약 대시보드
- manage_domain: 도메인 관리 (목록, 상세, 네임서버 설정)
- manage_server: 서버 관리 (목록, 상세, 시작/중지/재부팅)
- check_service: DDoS/VPN 서비스 상태 조회`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'get_dashboard',
description: '고객의 전체 자산 요약 대시보드를 조회합니다. 잔액, 서버 수, 도메인 수, 서비스 현황을 한눈에 보여줍니다.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function',
function: {
name: 'manage_domain',
description: '고객의 도메인을 관리합니다. 목록 조회, 상세 정보, 네임서버 설정 등을 수행합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['list', 'info', 'set_ns'],
description: '수행할 작업',
},
domain: {
type: 'string',
description: '대상 도메인명 (info, set_ns에 필요)',
},
nameservers: {
type: 'array',
items: { type: 'string' },
description: '설정할 네임서버 목록 (set_ns에 필요)',
},
},
required: ['action'],
},
},
},
{
type: 'function',
function: {
name: 'manage_server',
description: '고객의 서버를 관리합니다. 목록 조회, 상태 확인, 시작/중지/재부팅을 수행합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['list', 'info', 'start', 'stop', 'reboot'],
description: '수행할 작업',
},
server_id: {
type: 'number',
description: '대상 서버 ID (info, start, stop, reboot에 필요)',
},
},
required: ['action'],
},
},
},
{
type: 'function',
function: {
name: 'check_service',
description: '고객의 DDoS/VPN 서비스 상태를 조회합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'list'],
description: '수행할 작업',
},
service_type: {
type: 'string',
enum: ['ddos', 'vpn', 'all'],
description: '서비스 유형 (기본값: all)',
},
service_id: {
type: 'number',
description: '특정 서비스 ID (status에 필요)',
},
},
required: ['action'],
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: AssetSession,
context: AgentToolContext
): Promise<string> {
const { userId, db } = context;
switch (name) {
case 'get_dashboard':
return this.handleGetDashboard(userId, db);
case 'manage_domain':
return this.handleManageDomain(userId, args as unknown as ManageDomainArgs, db);
case 'manage_server':
return this.handleManageServer(userId, args as unknown as ManageServerArgs, db);
case 'check_service':
return this.handleCheckService(userId, args as unknown as CheckServiceArgs, db);
default:
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
}
private async handleGetDashboard(userId: string, db: D1Database): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
const [wallet, servers, domains, ddos, vpn] = await Promise.all([
db.prepare(`SELECT balance, currency FROM wallets WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running, SUM(monthly_price) as total_cost FROM servers WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active FROM domains WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, SUM(monthly_price) as total_cost FROM services_ddos WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, SUM(monthly_price) as total_cost FROM services_vpn WHERE user_id = ${userIdSubquery}`)
.bind(userId).first(),
]);
return JSON.stringify({
wallet: {
balance: wallet?.balance || 0,
currency: wallet?.currency || 'KRW',
},
servers: {
total: servers?.total || 0,
running: servers?.running || 0,
monthly_cost: servers?.total_cost || 0,
},
domains: {
total: domains?.total || 0,
active: domains?.active || 0,
},
ddos_services: {
total: ddos?.total || 0,
active: ddos?.active || 0,
monthly_cost: ddos?.total_cost || 0,
},
vpn_services: {
total: vpn?.total || 0,
active: vpn?.active || 0,
monthly_cost: vpn?.total_cost || 0,
},
});
} catch (error) {
logger.error('대시보드 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '대시보드 정보를 조회할 수 없습니다.' });
}
}
private async handleManageDomain(
userId: string,
args: ManageDomainArgs,
db: D1Database
): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
switch (args.action) {
case 'list': {
const domains = await db.prepare(
`SELECT id, domain, status, expiry_date, auto_renew
FROM domains WHERE user_id = ${userIdSubquery}
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
domains: domains.results || [],
count: domains.results?.length || 0,
});
}
case 'info': {
if (!args.domain) {
return JSON.stringify({ error: '도메인명을 지정해주세요.' });
}
const domain = await db.prepare(
`SELECT id, domain, status, registrar, nameservers, auto_renew, expiry_date, created_at
FROM domains WHERE user_id = ${userIdSubquery} AND domain = ?`
).bind(userId, args.domain).first();
if (!domain) {
return JSON.stringify({ error: '해당 도메인을 찾을 수 없습니다.' });
}
return JSON.stringify({ domain });
}
case 'set_ns': {
if (!args.domain || !args.nameservers) {
return JSON.stringify({ error: '도메인명과 네임서버 목록이 필요합니다.' });
}
// Create pending action for admin approval
await db.prepare(
`INSERT INTO pending_actions (user_id, action_type, target, params, status)
VALUES (${userIdSubquery}, 'set_nameservers', ?, ?, 'pending')`
).bind(userId, args.domain, JSON.stringify({ nameservers: args.nameservers })).run();
return JSON.stringify({
message: '네임서버 변경 요청이 접수되었습니다. 관리자 승인 후 적용됩니다.',
});
}
default:
return JSON.stringify({ error: `지원하지 않는 작업: ${args.action}` });
}
} catch (error) {
logger.error('도메인 관리 오류', error as Error, { userId });
return JSON.stringify({ error: '도메인 관리 중 오류가 발생했습니다.' });
}
}
private async handleManageServer(
userId: string,
args: ManageServerArgs,
db: D1Database
): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
switch (args.action) {
case 'list': {
const servers = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price
FROM servers WHERE user_id = ${userIdSubquery}
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
servers: servers.results || [],
count: servers.results?.length || 0,
});
}
case 'info': {
if (!args.server_id) {
return JSON.stringify({ error: '서버 ID를 지정해주세요.' });
}
const server = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price, provider, image, provisioned_at, expires_at
FROM servers WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.server_id).first();
if (!server) {
return JSON.stringify({ error: '해당 서버를 찾을 수 없습니다.' });
}
return JSON.stringify({ server });
}
case 'start':
case 'stop':
case 'reboot': {
if (!args.server_id) {
return JSON.stringify({ error: '서버 ID를 지정해주세요.' });
}
// Verify server belongs to user
const server = await db.prepare(
`SELECT id, status FROM servers WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.server_id).first();
if (!server) {
return JSON.stringify({ error: '해당 서버를 찾을 수 없습니다.' });
}
// Create pending action for admin approval
await db.prepare(
`INSERT INTO pending_actions (user_id, action_type, target, params, status)
VALUES (${userIdSubquery}, ?, ?, ?, 'pending')`
).bind(userId, `server_${args.action}`, `server:${args.server_id}`, JSON.stringify({ server_id: args.server_id })).run();
const actionLabels: Record<string, string> = {
start: '시작',
stop: '중지',
reboot: '재부팅',
};
return JSON.stringify({
message: `서버 ${actionLabels[args.action]} 요청이 접수되었습니다. 관리자 승인 후 실행됩니다.`,
});
}
default:
return JSON.stringify({ error: `지원하지 않는 작업: ${args.action}` });
}
} catch (error) {
logger.error('서버 관리 오류', error as Error, { userId });
return JSON.stringify({ error: '서버 관리 중 오류가 발생했습니다.' });
}
}
private async handleCheckService(
userId: string,
args: CheckServiceArgs,
db: D1Database
): Promise<string> {
try {
const userIdSubquery = `(SELECT id FROM users WHERE telegram_id = ?)`;
const result: Record<string, unknown> = {};
const serviceType = args.service_type || 'all';
if (serviceType === 'ddos' || serviceType === 'all') {
if (args.service_id && serviceType === 'ddos') {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, provider, monthly_price, expiry_date
FROM services_ddos WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.service_id).first();
result.ddos = ddos || { error: '해당 DDoS 서비스를 찾을 수 없습니다.' };
} else {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, monthly_price, expiry_date
FROM services_ddos WHERE user_id = ${userIdSubquery}`
).bind(userId).all();
result.ddos_services = ddos.results || [];
}
}
if (serviceType === 'vpn' || serviceType === 'all') {
if (args.service_id && serviceType === 'vpn') {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = ${userIdSubquery} AND id = ?`
).bind(userId, args.service_id).first();
result.vpn = vpn || { error: '해당 VPN 서비스를 찾을 수 없습니다.' };
} else {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = ${userIdSubquery}`
).bind(userId).all();
result.vpn_services = vpn.results || [];
}
}
return JSON.stringify(result);
} catch (error) {
logger.error('서비스 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '서비스 정보를 조회할 수 없습니다.' });
}
}
}

306
src/agents/base-agent.ts Normal file
View File

@@ -0,0 +1,306 @@
/**
* Base Agent - 모든 에이전트의 추상 기반 클래스
*
* 세션 라이프사이클, AI 호출 루프, 마커 처리를 통합하여
* 각 에이전트는 도메인 로직(프롬프트, 도구, 실행)만 구현하면 됩니다.
*
* 실행 전략:
* - multi-round: 도구 실행 -> 결과를 AI에 다시 전달 -> 최종 응답
* - single-shot: AI가 도구 호출 반환 -> 외부 실행 -> 결과 조합
*/
import type { Env, OpenAIToolCall, OpenAIAPIResponse, ToolDefinition } from '../types';
import type { BaseSession } from '../utils/session-manager';
import { SessionManager } from '../utils/session-manager';
import { createLogger } from '../utils/logger';
import { getOpenAIUrl } from '../utils/api-urls';
import { AI_CONFIG } from '../constants/agent-config';
export type ExecutionStrategy = 'multi-round' | 'single-shot';
export interface AgentToolContext {
userId: string;
env: Env;
db: D1Database;
}
export interface AgentAIResult {
response: string;
calledTools: string[];
toolCalls?: Array<{ name: string; arguments: Record<string, unknown> }>;
}
export abstract class BaseAgent<TSession extends BaseSession> {
protected abstract readonly agentName: string;
protected abstract readonly sessionManager: SessionManager<TSession>;
// --- Must implement ---
protected abstract getSystemPrompt(session: TSession): string;
protected abstract getTools(): ToolDefinition[];
protected abstract executeToolCall(
name: string,
args: Record<string, unknown>,
session: TSession,
context: AgentToolContext
): Promise<string>;
// --- Overridable hooks ---
protected getExecutionStrategy(): ExecutionStrategy { return 'multi-round'; }
protected getInitialStatus(): string { return 'gathering'; }
protected getMaxTokens(): number { return 800; }
protected getTemperature(): number { return 0.7; }
protected getErrorMessage(): string {
return '죄송합니다. 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
protected getMaxToolCallsMessage(): string { return '처리가 완료되었습니다.'; }
protected buildPromptSuffix(session: TSession): string {
return `\n\n## 현재 수집된 정보\n${JSON.stringify(session.collected_info, null, 2)}`;
}
protected async onSessionEnd(_session: TSession, _db: D1Database): Promise<void> {}
protected onBeforeSave(_session: TSession, _response: string, _calledTools: string[]): void {}
// --- Public API ---
async hasSession(db: D1Database, userId: string): Promise<boolean> {
return this.sessionManager.has(db, userId);
}
async processConsultation(
db: D1Database,
userId: string,
userMessage: string,
env: Env
): Promise<string> {
const log = createLogger(this.agentName);
const startTime = Date.now();
log.info('상담 시작', { userId, message: userMessage.substring(0, 100) });
try {
// 1. Get or create session
let session = await this.sessionManager.get(db, userId);
if (!session) {
session = this.sessionManager.create(userId, this.getInitialStatus());
}
// 2. Add user message
this.sessionManager.addMessage(session, 'user', userMessage);
// 3. Call AI
const context: AgentToolContext = { userId, env, db };
const aiResult = await this.callExpertAI(session, userMessage, env, context);
// 4. Handle PASSTHROUGH
if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) {
log.info('패스스루', { userId });
return '__PASSTHROUGH__';
}
// 5. Single-shot: execute tool calls returned by AI
let finalResponse = aiResult.response;
if (this.getExecutionStrategy() === 'single-shot'
&& aiResult.toolCalls && aiResult.toolCalls.length > 0) {
const toolResults: string[] = [];
for (const tc of aiResult.toolCalls) {
const result = await this.executeToolCall(tc.name, tc.arguments, session, context);
toolResults.push(result);
aiResult.calledTools.push(tc.name);
}
if (toolResults.length > 0) {
const cleanAiText = (aiResult.response || '')
.replace('__SESSION_END__', '').trim();
finalResponse = cleanAiText
? cleanAiText + '\n\n' + toolResults.join('\n\n')
: toolResults.join('\n\n');
}
}
// 6. Detect session end (both markers)
const isSessionEnd = finalResponse.includes('[세션 종료]')
|| aiResult.response.includes('__SESSION_END__');
if (isSessionEnd) {
finalResponse = finalResponse
.replace('[세션 종료]', '').replace('__SESSION_END__', '').trim();
log.info('세션 종료', { userId });
await this.onSessionEnd(session, db);
await this.sessionManager.delete(db, userId);
return finalResponse;
}
// 7. Before save hook
this.onBeforeSave(session, finalResponse, aiResult.calledTools);
// 8. Save session
this.sessionManager.addMessage(session, 'assistant', finalResponse);
await this.sessionManager.save(db, session);
log.info('상담 완료', {
userId,
duration: Date.now() - startTime,
calledTools: aiResult.calledTools.length,
});
return finalResponse;
} catch (error) {
log.error('상담 오류', error as Error, { userId });
return this.getErrorMessage();
}
}
// --- AI Call Loop ---
protected async callExpertAI(
session: TSession,
userMessage: string,
env: Env,
context: AgentToolContext
): Promise<AgentAIResult> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const log = createLogger(this.agentName);
const strategy = this.getExecutionStrategy();
const conversationHistory = session.messages.map(m => ({
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
content: m.content,
}));
const systemPrompt = this.getSystemPrompt(session) + this.buildPromptSuffix(session);
const messages: Array<{
role: string;
content: string | null;
tool_calls?: OpenAIToolCall[];
tool_call_id?: string;
name?: string;
}> = [
{ role: 'system', content: systemPrompt },
...conversationHistory,
{ role: 'user', content: userMessage },
];
const MAX_ROUNDS = AI_CONFIG.maxToolCalls;
let round = 0;
const calledTools: string[] = [];
while (round < MAX_ROUNDS) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000);
let response: Response;
try {
response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify({
model: AI_CONFIG.model,
messages,
tools: this.getTools(),
tool_choice: 'auto',
max_tokens: this.getMaxTokens(),
temperature: this.getTemperature(),
}),
});
if (!response.ok) {
const errorText = await response.text();
log.error('OpenAI API error', new Error(`HTTP ${response.status}`), {
status: response.status,
responsePreview: errorText.substring(0, 500),
});
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json() as OpenAIAPIResponse;
const assistantMessage = data.choices[0].message;
// Tool calls
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
log.info('도구 호출 요청', {
tools: assistantMessage.tool_calls.map(tc => tc.function.name),
});
// Single-shot: return tool calls without executing
if (strategy === 'single-shot') {
return {
response: assistantMessage.content || '',
calledTools,
toolCalls: assistantMessage.tool_calls.map(tc => ({
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments) as Record<string, unknown>,
})),
};
}
// Multi-round: execute and feed back to AI
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
});
for (const toolCall of assistantMessage.tool_calls) {
let args: Record<string, unknown>;
try {
args = JSON.parse(toolCall.function.arguments);
} catch (parseError) {
log.error('도구 인자 파싱 실패', parseError as Error, {
toolName: toolCall.function.name,
});
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: JSON.stringify({ error: '도구 인자 파싱 실패' }),
});
continue;
}
const result = await this.executeToolCall(
toolCall.function.name, args, session, context
);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
});
calledTools.push(toolCall.function.name);
}
round++;
continue;
}
// No tool calls - final response
const aiResponse = assistantMessage.content || '';
log.info('AI 응답', { response: aiResponse.slice(0, 200) });
if (aiResponse.includes('__PASSTHROUGH__')) {
return { response: '__PASSTHROUGH__', calledTools };
}
const sessionEnd = aiResponse.includes('__SESSION_END__');
const cleanResponse = aiResponse.replace('__SESSION_END__', '').trim();
return {
response: sessionEnd ? `${cleanResponse}\n\n[세션 종료]` : cleanResponse,
calledTools,
};
} finally {
clearTimeout(timeoutId);
}
}
log.warn('최대 도구 호출 라운드 도달', { round });
return { response: this.getMaxToolCallsMessage(), calledTools };
}
}

292
src/agents/billing-agent.ts Normal file
View File

@@ -0,0 +1,292 @@
/**
* Billing Agent - 예치금/결제 관리 에이전트
*
* 고객의 예치금 및 결제를 관리합니다:
* - 잔액 조회, 입금 계좌 안내
* - 입금 요청 (입금자명/금액 수집)
* - 거래 내역 조회
* - 입금 요청 취소
* - 금융 거래 시 optimistic locking
*/
import type { Env, ToolDefinition, BillingSession, ManageWalletArgs } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('billing');
const logger = createLogger('billing-agent');
class BillingSessionManager extends SessionManager<BillingSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
}
export class BillingAgent extends BaseAgent<BillingSession> {
protected readonly agentName = 'billing-agent';
protected readonly sessionManager = new BillingSessionManager();
protected getExecutionStrategy() { return 'single-shot' as const; }
protected getInitialStatus() { return 'collecting_amount'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: BillingSession): string {
return `당신은 호스팅/인프라 서비스의 결제 도우미입니다.
고객의 예치금 잔액 확인, 입금 요청, 거래 내역 조회를 도와줍니다.
## 주요 기능
1. **잔액 조회**: 현재 예치금 잔액 확인
2. **입금 계좌 안내**: 입금 계좌 정보 제공
3. **입금 요청**: 입금자명과 금액을 수집하여 입금 요청 생성
4. **거래 내역**: 최근 거래 내역 조회
5. **요청 취소**: 대기 중인 입금 요청 취소
## 입금 요청 프로세스
1. 고객에게 입금 금액 확인
2. 입금자명 확인 (실명)
3. 금액과 입금자명을 확인 후 입금 요청 생성
4. 입금 계좌 정보 안내
## 현재 세션 상태: ${session.status}
## 대화 원칙
- 항상 한국어로 응답하세요.
- 금액은 원(KRW) 단위로 표시하고, 천 단위 구분자를 사용하세요.
- 금융 관련이므로 정확한 금액 확인이 중요합니다. 항상 확인 절차를 거치세요.
- 결제와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 작업이 완료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- manage_wallet: 잔액 조회, 계좌 안내, 입금 요청, 거래 내역, 요청 취소`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'manage_wallet',
description: '고객의 예치금 지갑을 관리합니다. 잔액 조회, 입금 계좌 안내, 입금 요청, 거래 내역 조회, 요청 취소를 수행합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['balance', 'account', 'request', 'history', 'cancel'],
description: '수행할 작업: balance(잔액), account(계좌안내), request(입금요청), history(내역), cancel(취소)',
},
depositor_name: {
type: 'string',
description: '입금자명 (request에 필요)',
},
amount: {
type: 'number',
description: '입금 금액 (request에 필요)',
},
transaction_id: {
type: 'number',
description: '거래 ID (cancel에 필요)',
},
limit: {
type: 'number',
description: '조회 건수 제한 (history, 기본값: 10)',
},
},
required: ['action'],
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: BillingSession,
context: AgentToolContext
): Promise<string> {
if (name !== 'manage_wallet') {
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
const walletArgs = args as unknown as ManageWalletArgs;
const { userId, db, env } = context;
switch (walletArgs.action) {
case 'balance':
return this.handleBalance(userId, db);
case 'account':
return this.handleAccount(env);
case 'request':
return this.handleRequest(userId, walletArgs, db);
case 'history':
return this.handleHistory(userId, walletArgs.limit, db);
case 'cancel':
return this.handleCancel(userId, walletArgs.transaction_id, db);
default:
return JSON.stringify({ error: `지원하지 않는 작업: ${walletArgs.action}` });
}
}
private async handleBalance(userId: string, db: D1Database): Promise<string> {
try {
const wallet = await db.prepare(
`SELECT balance, currency, updated_at FROM wallets
WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)`
).bind(userId).first();
if (!wallet) {
return JSON.stringify({
balance: 0,
currency: 'KRW',
message: '지갑이 아직 생성되지 않았습니다.',
});
}
return JSON.stringify({
balance: wallet.balance,
currency: wallet.currency,
last_updated: wallet.updated_at,
});
} catch (error) {
logger.error('잔액 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '잔액 정보를 조회할 수 없습니다.' });
}
}
private handleAccount(env: Env): string {
const bankName = env.DEPOSIT_BANK_NAME || '(설정 필요)';
const bankAccount = env.DEPOSIT_BANK_ACCOUNT || '(설정 필요)';
const bankHolder = env.DEPOSIT_BANK_HOLDER || '(설정 필요)';
return JSON.stringify({
bank_name: bankName,
account_number: bankAccount,
account_holder: bankHolder,
notice: '입금 시 입금자명을 정확히 기재해주세요. 입금 확인은 영업일 기준 1~2시간 이내에 처리됩니다.',
});
}
private async handleRequest(
userId: string,
args: ManageWalletArgs,
db: D1Database
): Promise<string> {
if (!args.depositor_name || !args.amount) {
return JSON.stringify({ error: '입금자명과 금액이 필요합니다.' });
}
if (args.amount <= 0) {
return JSON.stringify({ error: '입금 금액은 0보다 커야 합니다.' });
}
if (args.amount > 10_000_000) {
return JSON.stringify({ error: '1회 최대 입금 금액은 10,000,000원입니다.' });
}
try {
// Generate name prefix for auto-matching (first 2 chars)
const namePrefix = args.depositor_name.substring(0, 2);
const result = await db.prepare(
`INSERT INTO transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
VALUES ((SELECT id FROM users WHERE telegram_id = ?), 'deposit', ?, 'pending', ?, ?, '텔레그램 봇 입금 요청')`
).bind(userId, args.amount, args.depositor_name, namePrefix).run();
if (!result.success) {
return JSON.stringify({ error: '입금 요청 생성에 실패했습니다.' });
}
return JSON.stringify({
success: true,
transaction_id: result.meta.last_row_id,
depositor_name: args.depositor_name,
amount: args.amount,
status: 'pending',
message: '입금 요청이 생성되었습니다. 입금 후 자동으로 확인됩니다.',
});
} catch (error) {
logger.error('입금 요청 생성 오류', error as Error, { userId });
return JSON.stringify({ error: '입금 요청 생성 중 오류가 발생했습니다.' });
}
}
private async handleHistory(
userId: string,
limit: number | undefined,
db: D1Database
): Promise<string> {
try {
const queryLimit = Math.min(limit || 10, 50);
const transactions = await db.prepare(
`SELECT id, type, amount, status, depositor_name, description, created_at, confirmed_at
FROM transactions
WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)
ORDER BY created_at DESC
LIMIT ?`
).bind(userId, queryLimit).all();
return JSON.stringify({
transactions: transactions.results || [],
count: transactions.results?.length || 0,
});
} catch (error) {
logger.error('거래 내역 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '거래 내역을 조회할 수 없습니다.' });
}
}
private async handleCancel(
userId: string,
transactionId: number | undefined,
db: D1Database
): Promise<string> {
if (!transactionId) {
return JSON.stringify({ error: '취소할 거래 ID를 지정해주세요.' });
}
try {
// Verify transaction belongs to user and is cancellable
const transaction = await db.prepare(
`SELECT id, status, amount FROM transactions
WHERE id = ? AND user_id = (SELECT id FROM users WHERE telegram_id = ?)
AND type = 'deposit' AND status = 'pending'`
).bind(transactionId, userId).first();
if (!transaction) {
return JSON.stringify({
error: '취소할 수 있는 거래를 찾을 수 없습니다. 대기(pending) 상태의 입금 요청만 취소 가능합니다.',
});
}
// Use optimistic locking via status check in WHERE clause
const result = await db.prepare(
`UPDATE transactions SET status = 'cancelled'
WHERE id = ? AND status = 'pending'`
).bind(transactionId).run();
if (!result.success || result.meta.changes === 0) {
return JSON.stringify({ error: '거래 취소에 실패했습니다. 이미 처리된 거래일 수 있습니다.' });
}
return JSON.stringify({
success: true,
transaction_id: transactionId,
amount: transaction.amount,
message: '입금 요청이 취소되었습니다.',
});
} catch (error) {
logger.error('거래 취소 오류', error as Error, { userId, transactionId });
return JSON.stringify({ error: '거래 취소 중 오류가 발생했습니다.' });
}
}
}

View File

@@ -0,0 +1,208 @@
/**
* Onboarding Agent - 신규 고객 상담 에이전트
*
* 서비스를 잘 모르는 신규 고객을 대상으로:
* - 사용 가능한 서비스 안내 (서버, 도메인, DDoS 방어, VPN)
* - 고객 니즈 파악 및 적절한 플랜 추천
* - D2 다이어그램으로 아키텍처 시각화
* - 지식 베이스 검색
*/
import type { Env, ToolDefinition, OnboardingSession, RenderD2Args } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { getD2RenderUrl } from '../utils/api-urls';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('onboarding');
const logger = createLogger('onboarding-agent');
class OnboardingSessionManager extends SessionManager<OnboardingSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
}
export class OnboardingAgent extends BaseAgent<OnboardingSession> {
protected readonly agentName = 'onboarding-agent';
protected readonly sessionManager = new OnboardingSessionManager();
protected getExecutionStrategy() { return 'multi-round' as const; }
protected getInitialStatus() { return 'greeting'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: OnboardingSession): string {
return `당신은 호스팅/인프라 서비스 회사의 친절한 고객 상담 AI입니다.
신규 고객이 서비스를 이해하고 적절한 플랜을 선택할 수 있도록 도와주세요.
## 제공 서비스
1. **서버 호스팅**: 클라우드 VPS, 전용 서버 (다양한 리전: 한국, 일본, 미국, 유럽)
2. **도메인 관리**: 도메인 등록, 이전, DNS 관리
3. **DDoS 방어**: Basic/Standard/Premium 등급 방어 서비스
4. **VPN 서비스**: WireGuard, OpenVPN, IPsec 프로토콜 지원
## 상담 프로세스
1. 고객 인사 및 니즈 파악
2. 사용 목적 확인 (웹 호스팅, 게임 서버, API 서버, 스트리밍 등)
3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항)
4. 예산 범위 확인
5. 적절한 서비스 조합 추천
6. 필요 시 D2 다이어그램으로 아키텍처 시각화
## 현재 세션 상태: ${session.status}
## 대화 원칙
- 항상 한국어로 응답하세요.
- 친절하고 전문적인 톤을 유지하세요.
- 고객이 기술에 익숙하지 않을 수 있으므로 쉬운 용어를 사용하세요.
- 한 번에 너무 많은 질문을 하지 마세요 (최대 2개).
- 고객의 니즈와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- 아키텍처 설명 시 render_d2로 다이어그램을 생성할 수 있습니다.
- 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'render_d2',
description: '아키텍처 다이어그램을 D2 언어로 렌더링합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.',
parameters: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'D2 다이어그램 소스 코드',
},
format: {
type: 'string',
enum: ['svg', 'png'],
description: '출력 형식 (기본값: svg)',
},
},
required: ['source'],
},
},
},
{
type: 'function',
function: {
name: 'search_knowledge',
description: '지식 베이스에서 서비스 관련 정보를 검색합니다. 가격, 사양, 정책, FAQ 등을 조회할 때 사용합니다.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '검색 키워드 또는 질문',
},
category: {
type: 'string',
enum: ['server', 'domain', 'ddos', 'vpn', 'billing', 'general'],
description: '검색 카테고리 (선택)',
},
},
required: ['query'],
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: OnboardingSession,
context: AgentToolContext
): Promise<string> {
switch (name) {
case 'render_d2':
return this.handleRenderD2(args as unknown as RenderD2Args, context.env);
case 'search_knowledge':
return this.handleSearchKnowledge(
args as { query: string; category?: string },
context.db
);
default:
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
}
private async handleRenderD2(args: RenderD2Args, env: Env): Promise<string> {
try {
const renderUrl = getD2RenderUrl(env);
const response = await fetch(`${renderUrl}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: args.source,
format: args.format || 'svg',
}),
});
if (!response.ok) {
logger.error('D2 렌더링 실패', new Error(`HTTP ${response.status}`));
return JSON.stringify({ error: 'D2 다이어그램 렌더링에 실패했습니다.' });
}
return JSON.stringify({
success: true,
message: '아키텍처 다이어그램이 생성되었습니다.',
format: args.format || 'svg',
});
} catch (error) {
logger.error('D2 렌더링 오류', error as Error);
return JSON.stringify({ error: 'D2 렌더링 서비스에 연결할 수 없습니다.' });
}
}
private async handleSearchKnowledge(
args: { query: string; category?: string },
db: D1Database
): Promise<string> {
try {
let sql = `SELECT title, content, category FROM knowledge_articles WHERE is_active = 1`;
const params: string[] = [];
if (args.category) {
sql += ` AND category = ?`;
params.push(args.category);
}
// Simple keyword search using LIKE
sql += ` AND (title LIKE ? OR content LIKE ? OR tags LIKE ?)`;
const keyword = `%${args.query}%`;
params.push(keyword, keyword, keyword);
sql += ` ORDER BY updated_at DESC LIMIT 5`;
const results = await db.prepare(sql).bind(...params).all();
if (!results.results || results.results.length === 0) {
return JSON.stringify({ results: [], message: '관련 문서를 찾을 수 없습니다.' });
}
const articles = results.results.map(r => ({
title: r.title,
content: (r.content as string).substring(0, 300),
category: r.category,
}));
return JSON.stringify({ results: articles });
} catch (error) {
logger.error('지식 베이스 검색 오류', error as Error);
return JSON.stringify({ error: '지식 베이스 검색에 실패했습니다.' });
}
}
}

View File

@@ -0,0 +1,295 @@
/**
* Troubleshoot Agent - 기존 고객 문제 해결 에이전트
*
* 기존 고객의 기술적 문제를 진단하고 해결합니다:
* - 체계적 정보 수집 (카테고리, 증상, 환경, 에러)
* - 상태 -> 원인 분석 -> 해결책 -> 예측 프로세스
* - 서버/도메인/서비스 상태 조회
* - 3라운드 이내 해결 불가 시 에스컬레이션
*/
import type { ToolDefinition, TroubleshootSession } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('troubleshoot');
const logger = createLogger('troubleshoot-agent');
class TroubleshootSessionManager extends SessionManager<TroubleshootSession> {
constructor() {
super({
tableName: config.tableName,
ttlMs: config.ttl * 1000,
maxMessages: config.maxMessages,
});
}
protected parseAdditionalFields(result: Record<string, unknown>): Partial<TroubleshootSession> {
return {
escalation_count: (result.escalation_count as number) || 0,
};
}
protected getAdditionalColumns(session: TroubleshootSession): Record<string, unknown> {
return {
escalation_count: session.escalation_count || 0,
};
}
}
export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
protected readonly agentName = 'troubleshoot-agent';
protected readonly sessionManager = new TroubleshootSessionManager();
protected getExecutionStrategy() { return 'multi-round' as const; }
protected getInitialStatus() { return 'gathering'; }
protected getMaxTokens() { return config.maxTokens; }
protected getTemperature() { return config.temperature; }
protected getSystemPrompt(session: TroubleshootSession): string {
return `당신은 호스팅/인프라 서비스의 기술 문제 해결 전문가입니다.
고객의 문제를 체계적으로 진단하고 해결책을 제시합니다.
## 문제 해결 프로세스
1. **상태 파악**: 현재 상태 확인 (서버 상태, 도메인 상태, 서비스 상태)
2. **원인 분석**: 수집된 정보를 기반으로 근본 원인 분석
3. **해결책 제시**: 단계별 해결 방법 안내
4. **예측**: 재발 방지 및 모니터링 권고
## 정보 수집 항목
- **카테고리**: 서버, 도메인, DDoS, VPN, 네트워크, 기타
- **증상**: 구체적 증상 (접속 불가, 느림, 오류 메시지 등)
- **환경**: OS, 브라우저, 리전, 사용 중인 서비스
- **에러 메시지**: 정확한 에러 메시지 또는 코드
## 현재 세션 상태: ${session.status}
## 에스컬레이션 카운트: ${session.escalation_count || 0}
## 대화 원칙
- 항상 한국어로 응답하세요.
- 전문적이지만 이해하기 쉽게 설명하세요.
- 문제와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
- 상담이 완료되면 __SESSION_END__를 응답 끝에 추가하세요.
- 3라운드 이내에 해결이 어려운 경우 __ESCALATE__를 응답에 포함하세요.
이 경우 고객에게 "전문 엔지니어에게 전달하겠습니다"라고 안내하세요.
## 도구 사용
- check_server_status: 서버 상태 확인
- check_domain_status: 도메인 상태 확인
- check_service_status: DDoS/VPN 서비스 상태 확인`;
}
protected getTools(): ToolDefinition[] {
return [
{
type: 'function',
function: {
name: 'check_server_status',
description: '고객의 서버 상태를 확인합니다. 서버 ID 또는 전체 목록을 조회할 수 있습니다.',
parameters: {
type: 'object',
properties: {
server_id: {
type: 'number',
description: '특정 서버 ID (생략 시 전체 목록)',
},
},
},
},
},
{
type: 'function',
function: {
name: 'check_domain_status',
description: '고객의 도메인 상태를 확인합니다. 도메인명 또는 전체 목록을 조회할 수 있습니다.',
parameters: {
type: 'object',
properties: {
domain: {
type: 'string',
description: '특정 도메인명 (생략 시 전체 목록)',
},
},
},
},
},
{
type: 'function',
function: {
name: 'check_service_status',
description: '고객의 DDoS/VPN 서비스 상태를 확인합니다.',
parameters: {
type: 'object',
properties: {
service_type: {
type: 'string',
enum: ['ddos', 'vpn', 'all'],
description: '서비스 유형 (기본값: all)',
},
service_id: {
type: 'number',
description: '특정 서비스 ID (생략 시 전체 목록)',
},
},
},
},
},
];
}
protected async executeToolCall(
name: string,
args: Record<string, unknown>,
_session: TroubleshootSession,
context: AgentToolContext
): Promise<string> {
const { userId, db } = context;
switch (name) {
case 'check_server_status':
return this.handleCheckServerStatus(userId, args.server_id as number | undefined, db);
case 'check_domain_status':
return this.handleCheckDomainStatus(userId, args.domain as string | undefined, db);
case 'check_service_status':
return this.handleCheckServiceStatus(
userId,
(args.service_type as string) || 'all',
args.service_id as number | undefined,
db
);
default:
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
}
}
protected onBeforeSave(session: TroubleshootSession, response: string, _calledTools: string[]): void {
// Handle escalation marker
if (response.includes('__ESCALATE__')) {
session.status = 'escalated';
session.escalation_count = (session.escalation_count || 0) + 1;
logger.info('에스컬레이션 발생', {
userId: session.user_id,
escalationCount: session.escalation_count,
});
}
}
private async handleCheckServerStatus(
userId: string,
serverId: number | undefined,
db: D1Database
): Promise<string> {
try {
if (serverId) {
const server = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price, provider
FROM servers WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND id = ?`
).bind(userId, serverId).first();
if (!server) {
return JSON.stringify({ error: '해당 서버를 찾을 수 없습니다.' });
}
return JSON.stringify({ server });
}
const servers = await db.prepare(
`SELECT id, label, ip_address, region, spec_label, status, monthly_price
FROM servers WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
servers: servers.results || [],
count: servers.results?.length || 0,
});
} catch (error) {
logger.error('서버 상태 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '서버 상태 조회에 실패했습니다.' });
}
}
private async handleCheckDomainStatus(
userId: string,
domain: string | undefined,
db: D1Database
): Promise<string> {
try {
if (domain) {
const result = await db.prepare(
`SELECT id, domain, status, registrar, nameservers, auto_renew, expiry_date
FROM domains WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND domain = ?`
).bind(userId, domain).first();
if (!result) {
return JSON.stringify({ error: '해당 도메인을 찾을 수 없습니다.' });
}
return JSON.stringify({ domain: result });
}
const domains = await db.prepare(
`SELECT id, domain, status, expiry_date
FROM domains WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)
ORDER BY created_at DESC`
).bind(userId).all();
return JSON.stringify({
domains: domains.results || [],
count: domains.results?.length || 0,
});
} catch (error) {
logger.error('도메인 상태 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '도메인 상태 조회에 실패했습니다.' });
}
}
private async handleCheckServiceStatus(
userId: string,
serviceType: string,
serviceId: number | undefined,
db: D1Database
): Promise<string> {
try {
const result: Record<string, unknown> = {};
if (serviceType === 'ddos' || serviceType === 'all') {
if (serviceId && serviceType === 'ddos') {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, provider, monthly_price, expiry_date
FROM services_ddos WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND id = ?`
).bind(userId, serviceId).first();
result.ddos = ddos || { error: '해당 DDoS 서비스를 찾을 수 없습니다.' };
} else {
const ddos = await db.prepare(
`SELECT id, target, protection_level, status, monthly_price, expiry_date
FROM services_ddos WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)`
).bind(userId).all();
result.ddos_services = ddos.results || [];
}
}
if (serviceType === 'vpn' || serviceType === 'all') {
if (serviceId && serviceType === 'vpn') {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?) AND id = ?`
).bind(userId, serviceId).first();
result.vpn = vpn || { error: '해당 VPN 서비스를 찾을 수 없습니다.' };
} else {
const vpn = await db.prepare(
`SELECT id, protocol, status, endpoint, monthly_price, expiry_date
FROM services_vpn WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)`
).bind(userId).all();
result.vpn_services = vpn.results || [];
}
}
return JSON.stringify(result);
} catch (error) {
logger.error('서비스 상태 조회 오류', error as Error, { userId });
return JSON.stringify({ error: '서비스 상태 조회에 실패했습니다.' });
}
}
}

View File

@@ -0,0 +1,56 @@
// ============================================
// Centralized Agent Configuration
// ============================================
export type AgentType = 'onboarding' | 'troubleshoot' | 'asset' | 'billing';
export const SESSION_TTL: Record<AgentType, number> = {
onboarding: 60 * 60 * 1000, // 1 hour
troubleshoot: 60 * 60 * 1000, // 1 hour
asset: 30 * 60 * 1000, // 30 minutes
billing: 30 * 60 * 1000, // 30 minutes
};
export const MAX_SESSION_MESSAGES: Record<AgentType, number> = {
onboarding: 20,
troubleshoot: 20,
asset: 15,
billing: 10,
};
export const AI_CONFIG = {
model: 'gpt-4o-mini' as const,
maxToolCalls: 3,
defaultTemperature: 0.7,
agents: {
onboarding: { maxTokens: 1024, temperature: 0.8 },
troubleshoot: { maxTokens: 1024, temperature: 0.5 },
asset: { maxTokens: 512, temperature: 0.3 },
billing: { maxTokens: 512, temperature: 0.3 },
} satisfies Record<AgentType, { maxTokens: number; temperature: number }>,
};
export const SESSION_TABLES: Record<AgentType, string> = {
onboarding: 'onboarding_sessions',
troubleshoot: 'troubleshoot_sessions',
asset: 'asset_sessions',
billing: 'billing_sessions',
};
export interface SessionConfig {
ttl: number;
maxMessages: number;
maxTokens: number;
temperature: number;
tableName: string;
}
export function getSessionConfig(agentType: AgentType): SessionConfig {
return {
ttl: SESSION_TTL[agentType],
maxMessages: MAX_SESSION_MESSAGES[agentType],
maxTokens: AI_CONFIG.agents[agentType].maxTokens,
temperature: AI_CONFIG.agents[agentType].temperature,
tableName: SESSION_TABLES[agentType],
};
}

20
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,20 @@
const en: Record<string, string> = {
greeting: 'Hello! This is the AI customer support system. How can I help you?',
greeting_new: 'Welcome! Feel free to ask anything about our services.',
error_general: 'Sorry, an error occurred while processing your request. Please try again later.',
error_rate_limit: 'Too many requests. Please try again later.',
error_blocked: 'Your account has been restricted.',
error_ai_unavailable: 'AI service is temporarily unstable. Please try again later.',
feedback_prompt: 'Was this consultation helpful?',
feedback_thanks: 'Thank you for your feedback!',
session_end: 'The consultation has ended. Feel free to reach out anytime for further inquiries.',
escalation_notice: 'Connecting you to a representative. Please wait a moment.',
escalation_admin: 'An escalation request has been received.',
action_pending: 'Your request has been submitted. Please wait for admin approval.',
action_approved: 'Your request has been approved and executed.',
action_rejected: 'Your request has been rejected.',
admin_only: 'This feature is available to administrators only.',
typing: 'Preparing a response...',
};
export default en;

33
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import ko from './ko';
import en from './en';
export type SupportedLanguage = 'ko' | 'en' | 'cn' | 'jp';
const messages: Record<string, Record<string, string>> = {
ko,
en,
// cn and jp fall back to ko until translations are added
};
const DEFAULT_LANGUAGE: SupportedLanguage = 'ko';
/**
* Get a localized message by key
* Falls back to Korean if key not found in requested language
*/
export function getMessage(
lang: SupportedLanguage | string,
key: string,
params?: Record<string, string | number>
): string {
const langMessages = messages[lang];
let text = langMessages?.[key] ?? messages[DEFAULT_LANGUAGE]?.[key] ?? key;
if (params) {
for (const [param, value] of Object.entries(params)) {
text = text.replace(`{${param}}`, String(value));
}
}
return text;
}

20
src/i18n/ko.ts Normal file
View File

@@ -0,0 +1,20 @@
const ko: Record<string, string> = {
greeting: '안녕하세요! AI 고객 지원 시스템입니다. 무엇을 도와드릴까요?',
greeting_new: '환영합니다! 저희 서비스에 대해 궁금한 점이 있으시면 무엇이든 물어보세요.',
error_general: '죄송합니다, 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
error_rate_limit: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.',
error_blocked: '이용이 제한된 계정입니다.',
error_ai_unavailable: 'AI 서비스가 일시적으로 불안정합니다. 잠시 후 다시 시도해주세요.',
feedback_prompt: '상담이 도움이 되셨나요?',
feedback_thanks: '피드백 감사합니다!',
session_end: '상담이 종료되었습니다. 추가 문의가 있으시면 언제든 말씀해주세요.',
escalation_notice: '담당자에게 연결 중입니다. 잠시만 기다려주세요.',
escalation_admin: '에스컬레이션 요청이 접수되었습니다.',
action_pending: '요청이 접수되었습니다. 관리자 승인을 기다려주세요.',
action_approved: '요청이 승인되어 실행되었습니다.',
action_rejected: '요청이 거부되었습니다.',
admin_only: '관리자만 사용할 수 있는 기능입니다.',
typing: '답변을 준비 중입니다...',
};
export default ko;

155
src/index.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Hono } from 'hono';
import type { Env } from './types';
import { webhookRouter } from './routes/webhook';
import { apiRouter } from './routes/api';
import { healthRouter } from './routes/health';
import { setWebhook, getWebhookInfo } from './telegram';
import { timingSafeEqual } from './security';
import { validateEnv } from './utils/env-validation';
import { createLogger } from './utils/logger';
const logger = createLogger('worker');
let envValidated = false;
const app = new Hono<{ Bindings: Env }>();
// Environment validation middleware (runs once per worker instance)
app.use('*', async (c, next) => {
if (!envValidated) {
const result = validateEnv(c.env as unknown as Record<string, unknown>);
if (!result.success) {
logger.error('Environment validation failed', new Error('Invalid configuration'), {
errors: result.errors,
});
return c.json({
error: 'Configuration error',
message: 'The worker is not properly configured.',
}, 500);
}
if (result.warnings.length > 0) {
logger.warn('Environment configuration warnings', { warnings: result.warnings });
}
logger.info('Environment validation passed', {
environment: c.env.ENVIRONMENT || 'production',
warnings: result.warnings.length,
});
envValidated = true;
}
return await next();
});
// Health check
app.route('/health', healthRouter);
// Setup webhook
app.get('/setup-webhook', async (c) => {
const env = c.env;
if (!env.BOT_TOKEN || !env.WEBHOOK_SECRET) {
return c.json({ error: 'Server configuration error' }, 500);
}
const token = c.req.query('token');
const secret = c.req.query('secret');
if (!token || !timingSafeEqual(token, env.BOT_TOKEN)) {
return c.text('Unauthorized', 401);
}
if (!secret || !timingSafeEqual(secret, env.WEBHOOK_SECRET)) {
return c.text('Unauthorized', 401);
}
const webhookUrl = `${new URL(c.req.url).origin}/webhook`;
const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET);
return c.json(result);
});
// Webhook info
app.get('/webhook-info', async (c) => {
const env = c.env;
if (!env.BOT_TOKEN || !env.WEBHOOK_SECRET) {
return c.json({ error: 'Server configuration error' }, 500);
}
const token = c.req.query('token');
const secret = c.req.query('secret');
if (!token || !timingSafeEqual(token, env.BOT_TOKEN)) {
return c.text('Unauthorized', 401);
}
if (!secret || !timingSafeEqual(secret, env.WEBHOOK_SECRET)) {
return c.text('Unauthorized', 401);
}
const result = await getWebhookInfo(env.BOT_TOKEN);
return c.json(result);
});
// API routes
app.route('/api', apiRouter);
// Telegram Webhook
app.route('/webhook', webhookRouter);
// Root
app.get('/', (c) => {
return c.text(
`Telegram AI Support Bot
Endpoints:
GET /health - Health check
GET /webhook-info - Webhook status
GET /setup-webhook - Configure webhook
POST /webhook - Telegram webhook (authenticated)
GET /api/* - Admin API (authenticated)`,
200
);
});
// 404
app.notFound((c) => c.text('Not Found', 404));
export default {
fetch: app.fetch,
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
const cronSchedule = event.cron;
logger.info('Cron job started', { schedule: cronSchedule });
const {
cleanupExpiredSessions,
sendExpiryNotifications,
archiveOldConversations,
cleanupStaleOrders,
monitoringCheck,
} = await import('./services/cron-jobs');
try {
switch (cronSchedule) {
// Midnight KST (15:00 UTC): expiry notifications, archiving, session cleanup
case '0 15 * * *':
await sendExpiryNotifications(env);
await archiveOldConversations(env);
await cleanupExpiredSessions(env);
break;
// Every 5 minutes: stale session/order cleanup
case '*/5 * * * *':
await cleanupStaleOrders(env);
break;
// Every hour: monitoring checks
case '0 * * * *':
await monitoringCheck(env);
break;
default:
logger.warn('Unknown cron schedule', { schedule: cronSchedule });
}
} catch (error) {
logger.error('Cron job failed', error as Error, { schedule: cronSchedule });
}
},
};

174
src/routes/api.ts Normal file
View File

@@ -0,0 +1,174 @@
import { Hono } from 'hono';
import type { Env, User, Transaction } from '../types';
import { timingSafeEqual } from '../security';
import { getPendingActions } from '../services/pending-actions';
import { sendMessage } from '../telegram';
import { createLogger } from '../utils/logger';
const logger = createLogger('api');
const api = new Hono<{ Bindings: Env }>();
// Admin API auth middleware
api.use('*', async (c, next) => {
// Support both Bearer token and query param auth
const authHeader = c.req.header('Authorization');
const queryToken = c.req.query('token');
let token: string | undefined;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7);
} else if (queryToken) {
token = queryToken;
}
if (!token || !timingSafeEqual(token, c.env.WEBHOOK_SECRET)) {
return c.json({ error: 'Unauthorized' }, 401);
}
return next();
});
// GET /api/stats - Service statistics
api.get('/stats', async (c) => {
try {
const db = c.env.DB;
const [users, txPending, servers, feedback] = await Promise.all([
db.prepare('SELECT COUNT(*) as count FROM users').first<{ count: number }>(),
db.prepare("SELECT COUNT(*) as count FROM transactions WHERE status = 'pending'").first<{ count: number }>(),
db.prepare("SELECT COUNT(*) as count FROM servers WHERE status != 'terminated'").first<{ count: number }>(),
db.prepare('SELECT AVG(rating) as avg, COUNT(*) as count FROM feedback').first<{ avg: number | null; count: number }>(),
]);
return c.json({
users: users?.count ?? 0,
pendingTransactions: txPending?.count ?? 0,
activeServers: servers?.count ?? 0,
feedback: {
avgRating: feedback?.avg ? Number(feedback.avg.toFixed(2)) : 0,
count: feedback?.count ?? 0,
},
});
} catch (error) {
logger.error('Stats query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/users - List users (paginated)
api.get('/users', async (c) => {
try {
const limit = Math.min(parseInt(c.req.query('limit') ?? '20'), 100);
const offset = parseInt(c.req.query('offset') ?? '0');
const result = await c.env.DB.prepare(
`SELECT id, telegram_id, username, first_name, role, is_blocked, last_active_at, created_at
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?`
)
.bind(limit, offset)
.all<Pick<User, 'id' | 'telegram_id' | 'username' | 'first_name' | 'role' | 'is_blocked' | 'last_active_at' | 'created_at'>>();
return c.json({ users: result.results, count: result.results.length });
} catch (error) {
logger.error('Users query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/transactions/pending - Pending transactions
api.get('/transactions/pending', async (c) => {
try {
const result = await c.env.DB.prepare(
`SELECT t.id, t.user_id, t.amount, t.depositor_name, t.status, t.created_at,
u.username, u.telegram_id
FROM transactions t
JOIN users u ON t.user_id = u.id
WHERE t.status = 'pending' AND t.type = 'deposit'
ORDER BY t.created_at DESC LIMIT 50`
)
.all<Pick<Transaction, 'id' | 'user_id' | 'amount' | 'depositor_name' | 'status' | 'created_at'> & {
username: string | null; telegram_id: string;
}>();
return c.json({ transactions: result.results });
} catch (error) {
logger.error('Pending transactions query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/audit-logs - Audit logs
api.get('/audit-logs', async (c) => {
try {
const limit = Math.min(parseInt(c.req.query('limit') ?? '50'), 200);
const result = await c.env.DB.prepare(
`SELECT id, actor_id, action, resource_type, resource_id, result, created_at
FROM audit_logs ORDER BY created_at DESC LIMIT ?`
)
.bind(limit)
.all();
return c.json({ logs: result.results });
} catch (error) {
logger.error('Audit logs query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// GET /api/pending-actions - Pending actions list
api.get('/pending-actions', async (c) => {
try {
const status = c.req.query('status');
const validStatuses = ['pending', 'approved', 'rejected', 'executed', 'failed'] as const;
const filterStatus = status && validStatuses.includes(status as typeof validStatuses[number])
? status as typeof validStatuses[number]
: undefined;
const actions = await getPendingActions(c.env.DB, filterStatus);
return c.json({ actions, count: actions.length });
} catch (error) {
logger.error('Pending actions query failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
// POST /api/broadcast - Send message to all users
api.post('/broadcast', async (c) => {
try {
const body = await c.req.json<{ message?: string }>();
if (!body.message || body.message.trim().length === 0) {
return c.json({ error: 'Message is required' }, 400);
}
if (body.message.length > 4000) {
return c.json({ error: 'Message too long (max 4000 chars)' }, 400);
}
const users = await c.env.DB
.prepare('SELECT telegram_id FROM users WHERE is_blocked = 0')
.all<{ telegram_id: string }>();
const telegramIds = users.results ?? [];
let sent = 0;
let failed = 0;
for (const user of telegramIds) {
try {
await sendMessage(c.env.BOT_TOKEN, parseInt(user.telegram_id), body.message);
sent++;
} catch {
failed++;
}
}
logger.info('Broadcast completed', { sent, failed, total: telegramIds.length });
return c.json({ sent, failed, total: telegramIds.length });
} catch (error) {
logger.error('Broadcast failed', error as Error);
return c.json({ error: 'Internal error' }, 500);
}
});
export { api as apiRouter };

View File

@@ -0,0 +1,242 @@
import {
answerCallbackQuery,
editMessageText,
sendMessage,
} from '../../telegram';
import { approvePendingAction, rejectPendingAction } from '../../services/pending-actions';
import { createFeedback } from '../../services/feedback';
import { createAuditLog } from '../../services/audit';
import { isAdmin } from '../../security';
import { createLogger } from '../../utils/logger';
import type { Env, CallbackQuery } from '../../types';
const logger = createLogger('callback-handler');
export async function handleCallbackQuery(
env: Env,
callbackQuery: CallbackQuery
): Promise<void> {
const { id: queryId, from, message, data } = callbackQuery;
if (!data || !message) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
return;
}
const chatId = message.chat.id;
const messageId = message.message_id;
const telegramUserId = from.id.toString();
try {
// Feedback: fb:{session_type}:{rating}
if (data.startsWith('fb:')) {
await handleFeedback(env, queryId, chatId, messageId, telegramUserId, data);
return;
}
// Action approval: act:{action_id}:{approve|reject}
if (data.startsWith('act:')) {
await handleActionApproval(env, queryId, chatId, messageId, telegramUserId, data);
return;
}
// Escalation: esc:{session_id}:{accept|reject}
if (data.startsWith('esc:')) {
await handleEscalation(env, queryId, chatId, messageId, telegramUserId, data);
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId);
} catch (error) {
logger.error('Callback handling error', error as Error, { data, telegramUserId });
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '처리 중 오류가 발생했습니다.' });
}
}
async function handleFeedback(
env: Env,
queryId: string,
chatId: number,
messageId: number,
telegramUserId: string,
data: string
): Promise<void> {
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const sessionType = parts[1];
const rating = parseInt(parts[2], 10);
if (isNaN(rating) || rating < 1 || rating > 5) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 평점입니다.' });
return;
}
const user = await env.DB
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(telegramUserId)
.first<{ id: number }>();
if (!user) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
return;
}
await createFeedback(env.DB, {
userId: user.id,
sessionType,
rating,
});
const stars = '⭐'.repeat(rating);
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '피드백 감사합니다!' });
await editMessageText(env.BOT_TOKEN, chatId, messageId, `피드백이 등록되었습니다. ${stars}\n감사합니다!`);
}
async function handleActionApproval(
env: Env,
queryId: string,
chatId: number,
messageId: number,
telegramUserId: string,
data: string
): Promise<void> {
// Only admins can approve/reject actions
if (!isAdmin(telegramUserId, env.ADMIN_TELEGRAM_IDS)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '관리자만 사용할 수 있습니다.' });
return;
}
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const actionId = parseInt(parts[1], 10);
const approve = parts[2] === 'approve';
if (isNaN(actionId)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 작업 ID입니다.' });
return;
}
const admin = await env.DB
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(telegramUserId)
.first<{ id: number }>();
if (!admin) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '관리자 정보를 찾을 수 없습니다.' });
return;
}
if (approve) {
const result = await approvePendingAction(env.DB, actionId, admin.id);
if (!result) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '이미 처리된 요청입니다.' });
return;
}
await createAuditLog(env.DB, {
actorId: admin.id,
action: 'approve_action',
resourceType: 'pending_action',
resourceId: String(actionId),
result: 'success',
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '승인되었습니다.' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`✅ 작업 #${actionId} 승인 완료\n유형: ${result.action_type}\n대상: ${result.target}`
);
// Notify user about the approval
if (result.user_id) {
const actionUser = await env.DB
.prepare('SELECT telegram_id FROM users WHERE id = ?')
.bind(result.user_id)
.first<{ telegram_id: string }>();
if (actionUser) {
await sendMessage(env.BOT_TOKEN, parseInt(actionUser.telegram_id), '요청이 승인되어 실행되었습니다.').catch(() => {});
}
}
} else {
const result = await rejectPendingAction(env.DB, actionId, admin.id);
if (!result) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '이미 처리된 요청입니다.' });
return;
}
await createAuditLog(env.DB, {
actorId: admin.id,
action: 'reject_action',
resourceType: 'pending_action',
resourceId: String(actionId),
result: 'success',
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '거부되었습니다.' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`❌ 작업 #${actionId} 거부\n유형: ${result.action_type}\n대상: ${result.target}`
);
// Notify user about the rejection
if (result.user_id) {
const actionUser = await env.DB
.prepare('SELECT telegram_id FROM users WHERE id = ?')
.bind(result.user_id)
.first<{ telegram_id: string }>();
if (actionUser) {
await sendMessage(env.BOT_TOKEN, parseInt(actionUser.telegram_id), '요청이 거부되었습니다.').catch(() => {});
}
}
}
}
async function handleEscalation(
env: Env,
queryId: string,
chatId: number,
messageId: number,
telegramUserId: string,
data: string
): Promise<void> {
if (!isAdmin(telegramUserId, env.ADMIN_TELEGRAM_IDS)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '관리자만 사용할 수 있습니다.' });
return;
}
const parts = data.split(':');
if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const sessionId = parts[1];
const action = parts[2];
if (action === 'accept') {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '에스컬레이션 수락' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`✅ 에스컬레이션 수락됨\n세션: ${sessionId}\n담당: 관리자`
);
// Notify the user their escalation was accepted
await sendMessage(
env.BOT_TOKEN,
parseInt(sessionId),
'관리자가 문의를 확인했습니다. 잠시만 기다려주세요.'
).catch(() => {});
} else {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '에스컬레이션 거부' });
await editMessageText(
env.BOT_TOKEN, chatId, messageId,
`❌ 에스컬레이션 거부됨\n세션: ${sessionId}`
);
}
}

View File

@@ -0,0 +1,371 @@
/**
* Message Handler - 텔레그램 메시지 처리의 핵심 모듈
*
* 메시지 수신 -> 사용자 등록/확인 -> 에이전트 라우팅 -> AI 응답 -> 피드백 수집
*/
import { sendMessage, sendMessageWithKeyboard, sendChatAction } from '../../telegram';
import { checkRateLimit } from '../../security';
import { registerAgent, routeToActiveAgent } from '../../agents/agent-registry';
import { OnboardingAgent } from '../../agents/onboarding-agent';
import { TroubleshootAgent } from '../../agents/troubleshoot-agent';
import { AssetAgent } from '../../agents/asset-agent';
import { BillingAgent } from '../../agents/billing-agent';
import { getMessage } from '../../i18n';
import {
ONBOARDING_PATTERNS,
TROUBLESHOOT_PATTERNS,
ASSET_PATTERNS,
BILLING_PATTERNS,
} from '../../utils/patterns';
import { selectToolsForMessage, executeTool } from '../../tools';
import { createLogger } from '../../utils/logger';
import { getOpenAIUrl } from '../../utils/api-urls';
import { AI_CONFIG } from '../../constants/agent-config';
import { escalateToAdmin, shouldEscalate } from '../../services/human-handoff';
import type { Env, TelegramUpdate, User, OpenAIAPIResponse, OpenAIMessage } from '../../types';
const logger = createLogger('message-handler');
// Register agent singletons with priorities (lower = checked first)
const onboardingAgent = new OnboardingAgent();
const troubleshootAgent = new TroubleshootAgent();
const assetAgent = new AssetAgent();
const billingAgent = new BillingAgent();
registerAgent('onboarding', onboardingAgent, 10);
registerAgent('troubleshoot', troubleshootAgent, 20);
registerAgent('asset', assetAgent, 30);
registerAgent('billing', billingAgent, 40);
export async function handleMessage(
env: Env,
update: TelegramUpdate
): Promise<void> {
if (!update.message?.text) return;
const message = update.message;
const chatId = message.chat.id;
const text = message.text!;
const telegramUserId = message.from.id.toString();
const lang = message.from.language_code ?? 'ko';
const requestId = crypto.randomUUID();
// 1. Check if user is blocked & register/update user
const user = await getOrCreateUser(env.DB, telegramUserId, message.from.first_name ?? '', message.from.username);
if (!user) {
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_general'));
return;
}
if (user.is_blocked) {
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_blocked'));
return;
}
// 2. Rate limiting
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_rate_limit'));
return;
}
// 3. Send typing action
await sendChatAction(env.BOT_TOKEN, chatId).catch(() => {});
try {
// 4. Route to active agent session first
const agentResponse = await routeToActiveAgent(env.DB, telegramUserId, text, env);
if (agentResponse) {
const { cleanText, sessionEnded } = cleanSessionMarkers(agentResponse);
// Handle escalation marker
if (agentResponse.includes('__ESCALATE__')) {
const cleaned = cleanText.replace('__ESCALATE__', '').trim();
await storeConversation(env.DB, user.id, text, cleaned, requestId);
await sendMessage(env.BOT_TOKEN, chatId, cleaned);
await escalateToAdmin(env, telegramUserId, text, 'agent');
return;
}
await storeConversation(env.DB, user.id, text, cleanText, requestId);
await sendMessage(env.BOT_TOKEN, chatId, cleanText);
// Prompt feedback after session end
if (sessionEnded) {
await promptFeedback(env, chatId, lang, 'agent');
}
return;
}
// 5. Detect intent for new session creation
let response: string | null = null;
if (ONBOARDING_PATTERNS.test(text)) {
response = await onboardingAgent.processConsultation(env.DB, telegramUserId, text, env);
} else if (TROUBLESHOOT_PATTERNS.test(text)) {
response = await troubleshootAgent.processConsultation(env.DB, telegramUserId, text, env);
} else if (BILLING_PATTERNS.test(text)) {
response = await billingAgent.processConsultation(env.DB, telegramUserId, text, env);
} else if (ASSET_PATTERNS.test(text)) {
response = await assetAgent.processConsultation(env.DB, telegramUserId, text, env);
}
if (response) {
const { cleanText, sessionEnded } = cleanSessionMarkers(response);
await storeConversation(env.DB, user.id, text, cleanText, requestId);
await sendMessage(env.BOT_TOKEN, chatId, cleanText);
if (sessionEnded) {
await promptFeedback(env, chatId, lang, 'agent');
}
return;
}
// 6. General AI fallback (no matching agent)
const aiResponse = await handleGeneralAI(env, user, text, telegramUserId);
await storeConversation(env.DB, user.id, text, aiResponse, requestId);
await sendMessage(env.BOT_TOKEN, chatId, aiResponse);
// 7. Check frustration for potential escalation
if (shouldEscalate(text, 0)) {
logger.warn('Frustration detected in general AI flow', { telegramUserId });
}
} catch (error) {
logger.error('Message handling error', error as Error, { telegramUserId, requestId });
await sendMessage(env.BOT_TOKEN, chatId, getMessage(lang, 'error_general'));
}
}
function cleanSessionMarkers(text: string): { cleanText: string; sessionEnded: boolean } {
const sessionEnded = text.includes('[세션 종료]') || text.includes('__SESSION_END__');
const cleanText = text
.replace('[세션 종료]', '')
.replace('__SESSION_END__', '')
.trim();
return { cleanText, sessionEnded };
}
async function getOrCreateUser(
db: D1Database,
telegramId: string,
firstName: string,
username?: string
): Promise<(User & { id: number }) | null> {
try {
await db
.prepare(
`INSERT INTO users (telegram_id, username, first_name, last_active_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (telegram_id) DO UPDATE SET
username = COALESCE(excluded.username, users.username),
first_name = COALESCE(excluded.first_name, users.first_name),
last_active_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`
)
.bind(telegramId, username ?? null, firstName)
.run();
const user = await db
.prepare('SELECT * FROM users WHERE telegram_id = ?')
.bind(telegramId)
.first<User>();
return user ?? null;
} catch (error) {
logger.error('User upsert failed', error as Error, { telegramId });
return null;
}
}
async function handleGeneralAI(
env: Env,
user: User,
userMessage: string,
telegramUserId: string
): Promise<string> {
if (!env.OPENAI_API_KEY) {
return await handleWorkersAIFallback(env, userMessage);
}
try {
// Load recent conversation context
const history = await env.DB
.prepare(
`SELECT role, content FROM conversations
WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
)
.bind(user.id, user.context_limit)
.all<{ role: string; content: string }>();
const conversationHistory = history.results.reverse();
const tools = selectToolsForMessage(userMessage);
const messages: OpenAIMessage[] = [
{
role: 'system',
content: `당신은 클라우드 호스팅/도메인/서버 관리 서비스의 AI 고객 지원 상담사입니다.
한국어로 친절하고 전문적으로 답변하세요.
기술 용어는 쉽게 풀어 설명하세요.
답변을 모르면 솔직히 모른다고 하고, 관리자 연결을 제안하세요.`,
},
...conversationHistory.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user', content: userMessage },
];
const body: Record<string, unknown> = {
model: AI_CONFIG.model,
messages,
max_tokens: 1024,
temperature: AI_CONFIG.defaultTemperature,
};
if (tools.length > 0) {
body.tools = tools;
body.tool_choice = 'auto';
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000);
try {
const response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify(body),
});
if (!response.ok) {
logger.error('OpenAI API error', new Error(`HTTP ${response.status}`));
return await handleWorkersAIFallback(env, userMessage);
}
const data = (await response.json()) as OpenAIAPIResponse;
const assistantMessage = data.choices[0].message;
// Handle tool calls (single round for general AI)
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
// Add assistant message with tool calls
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
});
for (const tc of assistantMessage.tool_calls) {
let args: Record<string, unknown>;
try {
args = JSON.parse(tc.function.arguments) as Record<string, unknown>;
} catch {
continue;
}
const result = await executeTool(tc.function.name, args, env, telegramUserId, env.DB);
messages.push({
role: 'tool',
content: result,
tool_call_id: tc.id,
name: tc.function.name,
});
}
// Second call to synthesize tool results
const followUp = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: AI_CONFIG.model,
messages,
max_tokens: 1024,
temperature: AI_CONFIG.defaultTemperature,
}),
});
if (followUp.ok) {
const followUpData = (await followUp.json()) as OpenAIAPIResponse;
return followUpData.choices[0].message.content ?? getMessage('ko', 'error_ai_unavailable');
}
// Fallback: return AI text
return assistantMessage.content ?? getMessage('ko', 'error_ai_unavailable');
}
return assistantMessage.content ?? getMessage('ko', 'error_ai_unavailable');
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
logger.error('General AI error', error as Error);
return await handleWorkersAIFallback(env, userMessage);
}
}
async function handleWorkersAIFallback(env: Env, userMessage: string): Promise<string> {
try {
const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct-fp8', {
messages: [
{
role: 'system',
content: '당신은 클라우드 호스팅 서비스의 AI 고객 지원 상담사입니다. 한국어로 친절하게 답변하세요.',
},
{ role: 'user', content: userMessage },
],
});
if (result && typeof result === 'object' && 'response' in result) {
return (result as { response: string }).response;
}
return getMessage('ko', 'error_ai_unavailable');
} catch (error) {
logger.error('Workers AI fallback error', error as Error);
return getMessage('ko', 'error_ai_unavailable');
}
}
async function storeConversation(
db: D1Database,
userId: number,
userMessage: string,
assistantResponse: string,
requestId: string
): Promise<void> {
try {
await db.batch([
db.prepare(
`INSERT INTO conversations (user_id, role, content, request_id) VALUES (?, 'user', ?, ?)`
).bind(userId, userMessage, requestId),
db.prepare(
`INSERT INTO conversations (user_id, role, content, request_id) VALUES (?, 'assistant', ?, ?)`
).bind(userId, assistantResponse, requestId),
]);
} catch (error) {
logger.error('Conversation storage failed', error as Error, { userId, requestId });
}
}
async function promptFeedback(
env: Env,
chatId: number,
lang: string,
sessionType: string
): Promise<void> {
try {
const text = getMessage(lang, 'feedback_prompt');
const keyboard = [
[1, 2, 3, 4, 5].map((rating) => ({
text: '⭐'.repeat(rating),
callback_data: `fb:${sessionType}:${rating}`,
})),
];
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, text, keyboard);
} catch (error) {
logger.error('Failed to send feedback prompt', error as Error);
}
}

13
src/routes/health.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Hono } from 'hono';
const health = new Hono();
health.get('/', (c) => {
return c.json({
status: 'ok',
service: 'telegram-ai-support',
timestamp: new Date().toISOString(),
});
});
export { health as healthRouter };

96
src/routes/webhook.ts Normal file
View File

@@ -0,0 +1,96 @@
import { Hono } from 'hono';
import { createMiddleware } from 'hono/factory';
import type { Env, TelegramUpdate } from '../types';
import { timingSafeEqual } from '../security';
import { handleCallbackQuery } from './handlers/callback-handler';
import { handleMessage } from './handlers/message-handler';
import { createLogger } from '../utils/logger';
const logger = createLogger('webhook');
const webhook = new Hono<{ Bindings: Env }>();
// Telegram webhook authentication middleware
const telegramAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
if (c.req.method !== 'POST') {
logger.warn('Invalid HTTP method', { method: c.req.method });
return c.text('Method not allowed', 405);
}
const contentType = c.req.header('Content-Type');
if (!contentType?.includes('application/json')) {
logger.warn('Invalid content type', { contentType });
return c.text('Invalid content type', 400);
}
const secretToken = c.req.header('X-Telegram-Bot-Api-Secret-Token');
if (!c.env.WEBHOOK_SECRET) {
logger.error('WEBHOOK_SECRET not configured', new Error('Missing WEBHOOK_SECRET'));
return c.text('Server configuration error', 500);
}
if (!timingSafeEqual(secretToken, c.env.WEBHOOK_SECRET)) {
logger.warn('Invalid webhook secret token');
return c.text('Unauthorized', 401);
}
const clientIP = c.req.header('CF-Connecting-IP');
if (clientIP) {
logger.debug('Request from IP', { clientIP });
}
return next();
});
webhook.post('/', telegramAuth, async (c) => {
let update: TelegramUpdate;
try {
update = await c.req.json<TelegramUpdate>();
} catch (error) {
logger.error('JSON parsing error', error as Error);
return c.json({ ok: true });
}
if (!update || typeof update.update_id !== 'number') {
logger.warn('Invalid update structure', { updateKeys: update ? Object.keys(update) : [] });
return c.json({ ok: true });
}
// Timestamp validation (5 minutes) - replay attack prevention
if (update.message?.date) {
const messageTime = update.message.date * 1000;
const now = Date.now();
const MAX_AGE_MS = 5 * 60 * 1000;
if (now - messageTime > MAX_AGE_MS) {
logger.warn('Message too old', { messageAge: Math.floor((now - messageTime) / 1000) });
return c.json({ ok: true });
}
}
try {
if (update.callback_query) {
await handleCallbackQuery(c.env, update.callback_query);
return c.json({ ok: true });
}
if (update.message) {
await handleMessage(c.env, update);
return c.json({ ok: true });
}
logger.debug('Unknown update type', { updateKeys: Object.keys(update) });
return c.json({ ok: true });
} catch (error) {
// Always return 200 to Telegram to prevent retries
logger.error('Webhook processing error', error as Error, {
updateId: update.update_id,
hasMessage: !!update.message,
hasCallback: !!update.callback_query,
});
return c.json({ ok: true });
}
});
export { webhook as webhookRouter };

161
src/security.ts Normal file
View File

@@ -0,0 +1,161 @@
import { Env, TelegramUpdate } from './types';
// Telegram server IP ranges (2024)
// https://core.telegram.org/bots/webhooks#the-short-version
const TELEGRAM_IP_RANGES = [
'149.154.160.0/20',
'91.108.4.0/22',
];
function ipInCIDR(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split('/');
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
const ipNum = ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
const rangeNum = range.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
return (ipNum & mask) === (rangeNum & mask);
}
function isValidTelegramIP(ip: string): boolean {
return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range));
}
/**
* Timing-safe string comparison to prevent timing attacks
*/
export function timingSafeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
if (!a || !b) return false;
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
function isValidSecretToken(request: Request, expectedSecret: string): boolean {
const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token');
return timingSafeEqual(secretHeader, expectedSecret);
}
function isValidRequestBody(body: unknown): body is TelegramUpdate {
return (
body !== null &&
typeof body === 'object' &&
'update_id' in body &&
typeof (body as TelegramUpdate).update_id === 'number'
);
}
// Reject messages older than 5 minutes (replay attack prevention)
function isRecentUpdate(message: TelegramUpdate['message']): boolean {
if (!message?.date) return true;
const messageTime = message.date * 1000;
const now = Date.now();
const MAX_AGE_MS = 5 * 60 * 1000;
return (now - messageTime) < MAX_AGE_MS;
}
export interface SecurityCheckResult {
valid: boolean;
error?: string;
update?: TelegramUpdate;
}
export async function validateWebhookRequest(
request: Request,
env: Env
): Promise<SecurityCheckResult> {
// 1. HTTP method
if (request.method !== 'POST') {
return { valid: false, error: 'Method not allowed' };
}
// 2. Content-Type
const contentType = request.headers.get('Content-Type');
if (!contentType?.includes('application/json')) {
return { valid: false, error: 'Invalid content type' };
}
// 3. Secret token (required)
if (!env.WEBHOOK_SECRET) {
console.error('[Security] WEBHOOK_SECRET not configured');
return { valid: false, error: 'Security configuration error' };
}
if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) {
console.warn('[Security] Invalid webhook secret token');
return { valid: false, error: 'Invalid secret token' };
}
// 4. IP whitelist (advisory - CF proxy may alter IP)
const clientIP = request.headers.get('CF-Connecting-IP');
if (clientIP && !isValidTelegramIP(clientIP)) {
console.warn('[Security] Request from non-Telegram IP:', clientIP);
}
// 5. Parse and validate body
let body: unknown;
try {
body = await request.json();
} catch {
return { valid: false, error: 'Invalid JSON body' };
}
if (!isValidRequestBody(body)) {
return { valid: false, error: 'Invalid request body structure' };
}
// 6. Timestamp check (replay attack prevention)
if (!isRecentUpdate(body.message)) {
return { valid: false, error: 'Message too old' };
}
return { valid: true, update: body };
}
/**
* Rate limiting using KV
* Returns true if request should be allowed
*/
export async function checkRateLimit(
kv: KVNamespace,
userId: string,
maxRequests: number = 30,
windowSeconds: number = 60
): Promise<boolean> {
const key = `rate:${userId}`;
const now = Math.floor(Date.now() / 1000);
try {
const raw = await kv.get(key, 'json') as { count: number; windowStart: number } | null;
if (!raw || now - raw.windowStart >= windowSeconds) {
await kv.put(key, JSON.stringify({ count: 1, windowStart: now }), { expirationTtl: windowSeconds });
return true;
}
if (raw.count >= maxRequests) {
return false;
}
await kv.put(key, JSON.stringify({ count: raw.count + 1, windowStart: raw.windowStart }), { expirationTtl: windowSeconds });
return true;
} catch (error) {
console.error('[Security] Rate limit check failed:', error);
return true; // Allow on error to avoid blocking legitimate requests
}
}
/**
* Check if a Telegram user ID is in the admin list
*/
export function isAdmin(telegramId: string | number, adminIds?: string): boolean {
if (!adminIds) return false;
const ids = adminIds.split(',').map(id => id.trim());
return ids.includes(String(telegramId));
}

48
src/services/audit.ts Normal file
View File

@@ -0,0 +1,48 @@
import { createLogger } from '../utils/logger';
const logger = createLogger('audit');
interface CreateAuditLogParams {
actorId: number | null;
action: string;
resourceType: string;
resourceId?: string | null;
details?: string | null;
result: 'success' | 'failure';
requestId?: string | null;
}
export async function createAuditLog(
db: D1Database,
params: CreateAuditLogParams
): Promise<void> {
try {
await db
.prepare(
`INSERT INTO audit_logs (actor_id, action, resource_type, resource_id, details, result, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(
params.actorId,
params.action,
params.resourceType,
params.resourceId ?? null,
params.details ?? null,
params.result,
params.requestId ?? null
)
.run();
logger.info('Audit log created', {
action: params.action,
resourceType: params.resourceType,
resourceId: params.resourceId,
result: params.result,
});
} catch (error) {
logger.error('Failed to create audit log', error as Error, {
action: params.action,
resourceType: params.resourceType,
});
}
}

300
src/services/cron-jobs.ts Normal file
View File

@@ -0,0 +1,300 @@
/**
* Cron Jobs - 스케줄 작업
*
* - cleanupExpiredSessions: 만료된 에이전트 세션 삭제
* - sendExpiryNotifications: 도메인/서버 만료 알림 (3일/1일)
* - archiveOldConversations: 90일 이상 대화 아카이빙
* - cleanupStaleOrders: 5분 이상 보류된 서버 주문 취소
* - monitoringCheck: 기본 헬스 체크 (DB 통계 로깅)
*/
import { createLogger } from '../utils/logger';
import { sendMessage } from '../telegram';
import { notifyAdmins } from './notification';
import type { Env } from '../types';
const logger = createLogger('cron-jobs');
const SESSION_TABLES = [
'onboarding_sessions',
'troubleshoot_sessions',
'asset_sessions',
'billing_sessions',
];
/**
* Delete expired rows from all agent session tables.
*/
export async function cleanupExpiredSessions(env: Env): Promise<void> {
const now = Date.now();
let totalDeleted = 0;
for (const table of SESSION_TABLES) {
try {
const result = await env.DB
.prepare(`DELETE FROM ${table} WHERE expires_at < ?`)
.bind(now)
.run();
const deleted = result.meta.changes ?? 0;
if (deleted > 0) {
totalDeleted += deleted;
logger.info('Expired sessions cleaned', { table, deleted });
}
} catch (error) {
logger.error(`Failed to clean ${table}`, error as Error);
}
}
if (totalDeleted > 0) {
logger.info('Total expired sessions cleaned', { totalDeleted });
}
}
/**
* Expiry notifications: domains/servers expiring within 3 days or 1 day.
*/
export async function sendExpiryNotifications(env: Env): Promise<void> {
try {
const db = env.DB;
// Domains expiring within 7 days (superset)
const expiringDomains = await db
.prepare(
`SELECT d.domain, d.expiry_date, u.telegram_id
FROM domains d
JOIN users u ON d.user_id = u.id
WHERE d.status = 'active'
AND d.expiry_date IS NOT NULL
AND d.expiry_date <= datetime('now', '+7 days')
AND d.expiry_date > datetime('now')
AND u.is_blocked = 0`
)
.all<{ domain: string; expiry_date: string; telegram_id: string }>();
for (const d of expiringDomains.results) {
const expiry = d.expiry_date.split('T')[0];
const daysLeft = Math.ceil(
(new Date(d.expiry_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const urgency = daysLeft <= 1 ? '🔴' : '⚠️';
await sendMessage(
env.BOT_TOKEN,
parseInt(d.telegram_id),
`${urgency} <b>도메인 만료 알림</b>\n\n` +
`도메인: <code>${d.domain}</code>\n` +
`만료일: ${expiry} (${daysLeft}일 남음)\n\n` +
`자동 갱신 설정을 확인해주세요.`
).catch((e) => logger.error('Domain expiry notification failed', e as Error));
}
// Servers expiring within 7 days
const expiringServers = await db
.prepare(
`SELECT s.label, s.id, s.ip_address, s.expires_at, u.telegram_id
FROM servers s
JOIN users u ON s.user_id = u.id
WHERE s.status = 'running'
AND s.expires_at IS NOT NULL
AND s.expires_at <= datetime('now', '+7 days')
AND s.expires_at > datetime('now')
AND u.is_blocked = 0`
)
.all<{ label: string | null; id: number; ip_address: string | null; expires_at: string; telegram_id: string }>();
for (const s of expiringServers.results) {
const name = s.label ?? s.ip_address ?? `서버 #${s.id}`;
const expiry = s.expires_at.split('T')[0];
const daysLeft = Math.ceil(
(new Date(s.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const urgency = daysLeft <= 1 ? '🔴' : '⚠️';
await sendMessage(
env.BOT_TOKEN,
parseInt(s.telegram_id),
`${urgency} <b>서버 만료 알림</b>\n\n` +
`서버: ${name}\n` +
`만료일: ${expiry} (${daysLeft}일 남음)\n\n` +
`연장이 필요하시면 문의해주세요.`
).catch((e) => logger.error('Server expiry notification failed', e as Error));
}
logger.info('Expiry notifications sent', {
domains: expiringDomains.results.length,
servers: expiringServers.results.length,
});
} catch (error) {
logger.error('Expiry notification job failed', error as Error);
}
}
/**
* Archive old conversations (90+ days).
* Creates summary archives and deletes original messages.
*/
export async function archiveOldConversations(env: Env): Promise<void> {
try {
const db = env.DB;
// Get users with old messages
const usersWithOldMessages = await db
.prepare(
`SELECT DISTINCT user_id, COUNT(*) as msg_count
FROM conversations
WHERE created_at < datetime('now', '-90 days')
GROUP BY user_id
LIMIT 50`
)
.all<{ user_id: number; msg_count: number }>();
let totalArchived = 0;
for (const u of usersWithOldMessages.results) {
// Get date range for old messages
const dateRange = await db
.prepare(
`SELECT MIN(created_at) as period_start, MAX(created_at) as period_end
FROM conversations
WHERE user_id = ? AND created_at < datetime('now', '-90 days')`
)
.bind(u.user_id)
.first<{ period_start: string; period_end: string }>();
if (!dateRange) continue;
// Create summary archive
await db
.prepare(
`INSERT INTO conversation_archives (user_id, summary, message_count, period_start, period_end)
VALUES (?, ?, ?, ?, ?)`
)
.bind(
u.user_id,
`${u.msg_count}개 메시지 아카이브`,
u.msg_count,
dateRange.period_start,
dateRange.period_end
)
.run();
// Delete archived messages
await db
.prepare(
`DELETE FROM conversations
WHERE user_id = ? AND created_at < datetime('now', '-90 days')`
)
.bind(u.user_id)
.run();
totalArchived += u.msg_count;
}
if (totalArchived > 0) {
logger.info('Conversation archiving completed', {
usersProcessed: usersWithOldMessages.results.length,
messagesArchived: totalArchived,
});
}
} catch (error) {
logger.error('Conversation archiving failed', error as Error);
}
}
/**
* Cancel pending server orders older than 5 minutes.
* Also cancels deposit transactions pending for more than 24 hours.
*/
export async function cleanupStaleOrders(env: Env): Promise<void> {
try {
const db = env.DB;
// Cancel stale server orders (5 min)
const staleServers = await db
.prepare(
`UPDATE servers SET status = 'failed', updated_at = CURRENT_TIMESTAMP
WHERE status = 'pending'
AND created_at < datetime('now', '-5 minutes')`
)
.run();
const serversCleaned = staleServers.meta.changes ?? 0;
// Cancel stale deposit transactions (24 hours)
const staleTx = await db
.prepare(
`UPDATE transactions
SET status = 'cancelled'
WHERE status = 'pending'
AND type = 'deposit'
AND created_at < datetime('now', '-24 hours')`
)
.run();
const txCleaned = staleTx.meta.changes ?? 0;
// Clean expired sessions too
const now = Date.now();
for (const table of SESSION_TABLES) {
await db.prepare(`DELETE FROM ${table} WHERE expires_at < ?`).bind(now).run();
}
if (serversCleaned > 0 || txCleaned > 0) {
logger.info('Stale cleanup completed', {
staleServers: serversCleaned,
cancelledTransactions: txCleaned,
});
}
} catch (error) {
logger.error('Stale cleanup failed', error as Error);
}
}
/**
* Basic health/monitoring check - log DB stats, alert on anomalies.
*/
export async function monitoringCheck(env: Env): Promise<void> {
try {
const db = env.DB;
// Check for high pending transaction count
const pendingCount = await db
.prepare("SELECT COUNT(*) as count FROM transactions WHERE status = 'pending'")
.first<{ count: number }>();
if (pendingCount && pendingCount.count > 20) {
await notifyAdmins(
env,
`⚠️ 모니터링 알림: 대기 중 거래가 ${pendingCount.count}건입니다. 확인이 필요합니다.`
);
}
// Check for pending actions older than 1 hour
const staleActions = await db
.prepare(
`SELECT COUNT(*) as count FROM pending_actions
WHERE status = 'pending' AND created_at < datetime('now', '-1 hours')`
)
.first<{ count: number }>();
if (staleActions && staleActions.count > 0) {
await notifyAdmins(
env,
`⚠️ 모니터링 알림: 1시간 이상 대기 중인 작업이 ${staleActions.count}건 있습니다.`
);
}
logger.info('Monitoring checks completed', {
pendingTx: pendingCount?.count ?? 0,
staleActions: staleActions?.count ?? 0,
});
} catch (error) {
logger.error('Monitoring checks failed', error as Error);
}
}
// Legacy aliases for backward compatibility
export { sendExpiryNotifications as notifyExpiringAssets };
export { cleanupStaleOrders as cleanupStalePending };
export { monitoringCheck as runMonitoringChecks };

73
src/services/feedback.ts Normal file
View File

@@ -0,0 +1,73 @@
import { createLogger } from '../utils/logger';
const logger = createLogger('feedback');
interface CreateFeedbackParams {
userId: number;
sessionType: string;
rating: number;
comment?: string;
}
interface FeedbackStats {
avgRating: number;
count: number;
}
export async function createFeedback(
db: D1Database,
params: CreateFeedbackParams
): Promise<void> {
try {
await db
.prepare(
`INSERT INTO feedback (user_id, session_type, rating, comment)
VALUES (?, ?, ?, ?)`
)
.bind(params.userId, params.sessionType, params.rating, params.comment ?? null)
.run();
logger.info('Feedback created', {
userId: params.userId,
sessionType: params.sessionType,
rating: params.rating,
});
} catch (error) {
logger.error('Failed to create feedback', error as Error, {
userId: params.userId,
});
throw error;
}
}
export async function getFeedbackStats(
db: D1Database,
sessionType?: string
): Promise<FeedbackStats> {
try {
let result;
if (sessionType) {
result = await db
.prepare(
`SELECT AVG(rating) as avg_rating, COUNT(*) as count
FROM feedback WHERE session_type = ?`
)
.bind(sessionType)
.first<{ avg_rating: number | null; count: number }>();
} else {
result = await db
.prepare(
`SELECT AVG(rating) as avg_rating, COUNT(*) as count FROM feedback`
)
.first<{ avg_rating: number | null; count: number }>();
}
return {
avgRating: result?.avg_rating ?? 0,
count: result?.count ?? 0,
};
} catch (error) {
logger.error('Failed to get feedback stats', error as Error);
return { avgRating: 0, count: 0 };
}
}

View File

@@ -0,0 +1,161 @@
/**
* Human Handoff Service - 사람 에스컬레이션 메커니즘
*
* AI가 처리할 수 없는 상황을 감지하고 관리자에게 전달합니다.
* - shouldEscalate: 단일 메시지 기반 에스컬레이션 판단
* - detectFrustration: 최근 메시지 배열 분석
* - escalateToAdmin: 관리자에게 에스컬레이션 알림 발송
* - handleAdminTakeover: 관리자가 사용자 대화를 인수
*/
import { createLogger } from '../utils/logger';
import { sendMessage, sendMessageWithKeyboard } from '../telegram';
import { notifyAdmins } from './notification';
import type { Env } from '../types';
const logger = createLogger('human-handoff');
// Frustration detection patterns
const FRUSTRATION_PATTERNS = /답답|화나|짜증|말이\s*안\s*통|쓸모.*없|다시\s*말해|아니\s*그게|못\s*알아|엉뚱|안\s*됐|계속\s*안|반복|다른\s*상담|사람.*연결|상담사|책임자|관리자|매니저/i;
const FRUSTRATION_PATTERNS_EN = /human|agent|operator|help me|useless|not working|frustrated|angry/i;
const REPEATED_FAILURE_THRESHOLD = 3;
/**
* Check if escalation to a human is needed (single message check)
*/
export function shouldEscalate(
userMessage: string,
escalationCount: number
): boolean {
if (FRUSTRATION_PATTERNS.test(userMessage)) return true;
if (FRUSTRATION_PATTERNS_EN.test(userMessage)) return true;
if (escalationCount >= REPEATED_FAILURE_THRESHOLD) return true;
return false;
}
/**
* Analyze recent messages for frustration signals.
* Returns true if escalation should be considered.
*/
export function detectFrustration(
messages: Array<{ role: string; content: string }>
): boolean {
const userMessages = messages.filter(m => m.role === 'user');
// Check each message for frustration patterns
for (const msg of userMessages) {
if (FRUSTRATION_PATTERNS.test(msg.content)) return true;
if (FRUSTRATION_PATTERNS_EN.test(msg.content)) return true;
}
// Detect repeated similar messages (3+ identical messages = frustration)
if (userMessages.length >= 3) {
const recent = userMessages.slice(-3);
const unique = new Set(recent.map(m => m.content.toLowerCase().trim()));
if (unique.size === 1) return true;
}
return false;
}
/**
* Escalate conversation to admin with inline keyboard
*/
export async function escalateToAdmin(
env: Env,
userId: string,
userMessage: string,
sessionType: string,
context?: string
): Promise<string> {
try {
const adminIds = env.ADMIN_TELEGRAM_IDS;
if (!adminIds) {
logger.warn('No admin IDs configured for escalation');
return '현재 관리자 연결이 불가합니다. 잠시 후 다시 시도해주세요.';
}
const ids = adminIds.split(',').map((id) => id.trim()).filter(Boolean);
if (ids.length === 0) {
return '현재 관리자 연결이 불가합니다. 잠시 후 다시 시도해주세요.';
}
// Get user info
const user = await env.DB
.prepare('SELECT username, first_name, telegram_id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ username: string | null; first_name: string | null; telegram_id: string }>();
const userName = user?.username ? `@${user.username}` : (user?.first_name ?? userId);
const escalationMessage = [
'🚨 <b>에스컬레이션 요청</b>',
'',
`사용자: ${userName} (${userId})`,
`세션: ${sessionType}`,
`메시지: ${userMessage.substring(0, 200)}`,
context ? `맥락: ${context}` : '',
'',
`시간: ${new Date().toISOString()}`,
]
.filter(Boolean)
.join('\n');
// Send to all admins with action buttons
for (const adminId of ids) {
await sendMessageWithKeyboard(
env.BOT_TOKEN,
parseInt(adminId),
escalationMessage,
[
[
{ text: '✅ 수락', callback_data: `esc:${userId}:accept` },
{ text: '❌ 거부', callback_data: `esc:${userId}:reject` },
],
]
).catch((e) => logger.error('Escalation notification failed', e as Error, { adminId }));
}
logger.info('Escalation sent to admins', {
userId,
sessionType,
adminCount: ids.length,
});
return '관리자에게 연결 요청을 보냈습니다. 잠시만 기다려주세요.';
} catch (error) {
logger.error('Escalation failed', error as Error, { userId });
return '관리자 연결 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
/**
* Admin takes over a user's conversation.
* Sends a message to the user on behalf of the admin.
*/
export async function handleAdminTakeover(
env: Env,
adminId: string,
userId: string,
message: string
): Promise<void> {
logger.info('Admin takeover', { adminId, userId });
try {
await sendMessage(env.BOT_TOKEN, parseInt(userId), message);
// Store the admin message as a conversation entry
await env.DB
.prepare(
`INSERT INTO conversations (user_id, role, content, request_id)
VALUES ((SELECT id FROM users WHERE telegram_id = ?), 'assistant', ?, ?)`
)
.bind(userId, `[관리자] ${message}`, crypto.randomUUID())
.run();
} catch (error) {
logger.error('Admin takeover failed', error as Error, { adminId, userId });
await notifyAdmins(env, `⚠️ 사용자 ${userId}에게 메시지 전송 실패`);
}
}

117
src/services/kv-cache.ts Normal file
View File

@@ -0,0 +1,117 @@
import { createLogger } from '../utils/logger';
const logger = createLogger('kv-cache');
/**
* KV Cache abstraction layer for consistent caching patterns
*/
export class KVCache {
constructor(private kv: KVNamespace, private prefix: string = '') {}
async get<T>(key: string): Promise<T | null> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
try {
const value = await this.kv.get(fullKey, 'json');
return value as T | null;
} catch (error) {
logger.error('KV get failed', error as Error, { key: fullKey });
return null;
}
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<boolean> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
try {
const options = ttlSeconds ? { expirationTtl: ttlSeconds } : undefined;
await this.kv.put(fullKey, JSON.stringify(value), options);
return true;
} catch (error) {
logger.error('KV set failed', error as Error, { key: fullKey });
return false;
}
}
async delete(key: string): Promise<boolean> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
try {
await this.kv.delete(fullKey);
return true;
} catch (error) {
logger.error('KV delete failed', error as Error, { key: fullKey });
return false;
}
}
/**
* Get or set pattern - fetch from cache or compute and store
*/
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttlSeconds?: number
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) {
logger.debug('Cache hit', { key });
return cached;
}
logger.debug('Cache miss', { key });
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
async exists(key: string): Promise<boolean> {
const value = await this.get(key);
return value !== null;
}
}
/**
* Create rate limiter cache instance
*/
export function createRateLimitCache(kv: KVNamespace): KVCache {
return new KVCache(kv, 'rate');
}
/**
* Create session cache instance
*/
export function createSessionCache(kv: KVNamespace): KVCache {
return new KVCache(kv, 'session');
}
/**
* Create query/general cache instance
*/
export function createQueryCache(kv: KVNamespace): KVCache {
return new KVCache(kv, 'query');
}
/**
* Rate limiting helper - returns true if request should be allowed
*/
export async function checkRateLimitWithCache(
cache: KVCache,
userId: string,
maxRequests: number = 30,
windowSeconds: number = 60
): Promise<boolean> {
const key = userId;
const now = Math.floor(Date.now() / 1000);
const data = await cache.get<{ count: number; windowStart: number }>(key);
if (!data || now - data.windowStart >= windowSeconds) {
await cache.set(key, { count: 1, windowStart: now }, windowSeconds);
return true;
}
if (data.count >= maxRequests) {
return false;
}
await cache.set(key, { count: data.count + 1, windowStart: data.windowStart }, windowSeconds);
return true;
}

View File

@@ -0,0 +1,60 @@
import { createLogger } from '../utils/logger';
import type { Env } from '../types';
const logger = createLogger('notification');
const TELEGRAM_API = 'https://api.telegram.org';
export async function notifyAdmins(env: Env, message: string): Promise<void> {
const adminIds = env.ADMIN_TELEGRAM_IDS;
if (!adminIds) {
logger.warn('ADMIN_TELEGRAM_IDS not configured, skipping notification');
return;
}
const ids = adminIds
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (ids.length === 0) {
logger.warn('No admin IDs found after parsing');
return;
}
const results = await Promise.allSettled(
ids.map((chatId) => sendTelegramMessage(env.BOT_TOKEN, chatId, message))
);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
logger.error('Some admin notifications failed', undefined, {
total: ids.length,
failed: failed.length,
});
} else {
logger.info('All admin notifications sent', { count: ids.length });
}
}
async function sendTelegramMessage(
botToken: string,
chatId: string,
text: string
): Promise<void> {
const url = `${TELEGRAM_API}/bot${botToken}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Telegram API error ${response.status}: ${body}`);
}
}

View File

@@ -0,0 +1,135 @@
import { createLogger } from '../utils/logger';
import type { PendingAction, PendingActionStatus } from '../types';
const logger = createLogger('pending-actions');
interface CreatePendingActionParams {
userId: number;
actionType: string;
target: string;
params: Record<string, unknown>;
}
export async function createPendingAction(
db: D1Database,
params: CreatePendingActionParams
): Promise<PendingAction> {
const result = await db
.prepare(
`INSERT INTO pending_actions (user_id, action_type, target, params)
VALUES (?, ?, ?, ?)
RETURNING *`
)
.bind(
params.userId,
params.actionType,
params.target,
JSON.stringify(params.params)
)
.first<PendingAction>();
if (!result) {
throw new Error('Failed to create pending action');
}
logger.info('Pending action created', {
actionId: result.id,
actionType: params.actionType,
target: params.target,
});
return result;
}
export async function approvePendingAction(
db: D1Database,
actionId: number,
approvedBy: number
): Promise<PendingAction | null> {
const result = await db
.prepare(
`UPDATE pending_actions
SET status = 'approved', approved_by = ?
WHERE id = ? AND status = 'pending'
RETURNING *`
)
.bind(approvedBy, actionId)
.first<PendingAction>();
if (!result) {
logger.warn('Pending action not found or not in pending status', { actionId });
return null;
}
logger.info('Pending action approved', { actionId, approvedBy });
return result;
}
export async function rejectPendingAction(
db: D1Database,
actionId: number,
approvedBy: number
): Promise<PendingAction | null> {
const result = await db
.prepare(
`UPDATE pending_actions
SET status = 'rejected', approved_by = ?
WHERE id = ? AND status = 'pending'
RETURNING *`
)
.bind(approvedBy, actionId)
.first<PendingAction>();
if (!result) {
logger.warn('Pending action not found or not in pending status', { actionId });
return null;
}
logger.info('Pending action rejected', { actionId, approvedBy });
return result;
}
export async function executePendingAction(
db: D1Database,
actionId: number
): Promise<PendingAction | null> {
const result = await db
.prepare(
`UPDATE pending_actions
SET status = 'executed', executed_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'approved'
RETURNING *`
)
.bind(actionId)
.first<PendingAction>();
if (!result) {
logger.warn('Pending action not found or not approved', { actionId });
return null;
}
logger.info('Pending action executed', { actionId });
return result;
}
export async function getPendingActions(
db: D1Database,
status?: PendingActionStatus
): Promise<PendingAction[]> {
if (status) {
const result = await db
.prepare(
`SELECT * FROM pending_actions WHERE status = ? ORDER BY created_at DESC LIMIT 50`
)
.bind(status)
.all<PendingAction>();
return result.results;
}
const result = await db
.prepare(
`SELECT * FROM pending_actions ORDER BY created_at DESC LIMIT 50`
)
.all<PendingAction>();
return result.results;
}

235
src/telegram.ts Normal file
View File

@@ -0,0 +1,235 @@
// ============================================
// Telegram Bot API Helpers
// ============================================
export class TelegramError extends Error {
constructor(
message: string,
public readonly code?: number,
public readonly description?: string
) {
super(message);
this.name = 'TelegramError';
}
}
export interface InlineKeyboardButton {
text: string;
url?: string;
callback_data?: string;
web_app?: { url: string };
}
async function callTelegramAPI(
token: string,
method: string,
body: Record<string, unknown>
): Promise<Response> {
const response = await fetch(
`https://api.telegram.org/bot${token}/${method}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!response.ok) {
let description = '';
try {
const errorData = await response.json() as { description?: string };
description = errorData.description || '';
} catch {
// JSON parse failure ignored
}
throw new TelegramError(
`Telegram API ${method} failed: ${response.status}`,
response.status,
description
);
}
return response;
}
function wrapTelegramCall(method: string, fn: () => Promise<Response>): Promise<void> {
return fn().then(() => undefined).catch((error: unknown) => {
if (error instanceof TelegramError) throw error;
throw new TelegramError(
`Network error in ${method}`,
undefined,
error instanceof Error ? error.message : String(error)
);
});
}
export async function sendMessage(
token: string,
chatId: number,
text: string,
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_to_message_id?: number;
disable_notification?: boolean;
}
): Promise<void> {
return wrapTelegramCall('sendMessage', () =>
callTelegramAPI(token, 'sendMessage', {
chat_id: chatId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_to_message_id: options?.reply_to_message_id,
disable_notification: options?.disable_notification,
})
);
}
export async function sendMessageWithKeyboard(
token: string,
chatId: number,
text: string,
keyboard: InlineKeyboardButton[][],
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
}
): Promise<void> {
return wrapTelegramCall('sendMessageWithKeyboard', () =>
callTelegramAPI(token, 'sendMessage', {
chat_id: chatId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_markup: { inline_keyboard: keyboard },
})
);
}
export async function sendChatAction(
token: string,
chatId: number,
action: 'typing' | 'upload_photo' | 'upload_document' = 'typing'
): Promise<void> {
return wrapTelegramCall('sendChatAction', () =>
callTelegramAPI(token, 'sendChatAction', {
chat_id: chatId,
action,
})
);
}
export async function answerCallbackQuery(
token: string,
callbackQueryId: string,
options?: {
text?: string;
show_alert?: boolean;
}
): Promise<void> {
return wrapTelegramCall('answerCallbackQuery', () =>
callTelegramAPI(token, 'answerCallbackQuery', {
callback_query_id: callbackQueryId,
text: options?.text,
show_alert: options?.show_alert,
})
);
}
export async function editMessageText(
token: string,
chatId: number,
messageId: number,
text: string,
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_markup?: { inline_keyboard: InlineKeyboardButton[][] };
}
): Promise<void> {
return wrapTelegramCall('editMessageText', () =>
callTelegramAPI(token, 'editMessageText', {
chat_id: chatId,
message_id: messageId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_markup: options?.reply_markup,
})
);
}
export async function sendPhoto(
token: string,
chatId: number,
photo: ArrayBuffer,
options?: {
caption?: string;
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_to_message_id?: number;
}
): Promise<void> {
const formData = new FormData();
formData.append('chat_id', String(chatId));
formData.append('photo', new Blob([photo], { type: 'image/png' }), 'diagram.png');
if (options?.caption) {
formData.append('caption', options.caption);
formData.append('parse_mode', options.parse_mode || 'HTML');
}
if (options?.reply_to_message_id) {
formData.append('reply_to_message_id', String(options.reply_to_message_id));
}
try {
const response = await fetch(
`https://api.telegram.org/bot${token}/sendPhoto`,
{ method: 'POST', body: formData }
);
if (!response.ok) {
let description = '';
try {
const errorData = await response.json() as { description?: string };
description = errorData.description || '';
} catch {
// JSON parse failure ignored
}
throw new TelegramError(
`Telegram API sendPhoto failed: ${response.status}`,
response.status,
description
);
}
} catch (error) {
if (error instanceof TelegramError) throw error;
throw new TelegramError(
'Network error in sendPhoto',
undefined,
error instanceof Error ? error.message : String(error)
);
}
}
export async function setWebhook(
token: string,
webhookUrl: string,
secretToken: string
): Promise<{ ok: boolean; description?: string }> {
const response = await fetch(
`https://api.telegram.org/bot${token}/setWebhook`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: webhookUrl,
secret_token: secretToken,
allowed_updates: ['message', 'callback_query'],
drop_pending_updates: true,
}),
}
);
return response.json() as Promise<{ ok: boolean; description?: string }>;
}
export async function getWebhookInfo(token: string): Promise<unknown> {
const response = await fetch(
`https://api.telegram.org/bot${token}/getWebhookInfo`
);
return response.json();
}

371
src/tools/admin-tool.ts Normal file
View File

@@ -0,0 +1,371 @@
import { createLogger } from '../utils/logger';
import { createAuditLog } from '../services/audit';
import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock';
import type { Env, ToolDefinition, AdminArgs, UserRole } from '../types';
const logger = createLogger('admin-tool');
export const adminTool: ToolDefinition = {
type: 'function',
function: {
name: 'admin',
description:
'관리자 전용 도구: 사용자 차단/해제, 역할 변경, 공지 발송, 입금 승인/거부, 대기 목록 조회.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: [
'block_user',
'unblock_user',
'set_role',
'broadcast',
'confirm_deposit',
'reject_deposit',
'list_pending',
],
description: '관리자 작업',
},
target_user_id: {
type: 'string',
description: '대상 사용자 Telegram ID (block/unblock/set_role)',
},
role: {
type: 'string',
enum: ['admin', 'user'],
description: '설정할 역할 (set_role)',
},
message: {
type: 'string',
description: '공지 메시지 (broadcast)',
},
transaction_id: {
type: 'number',
description: '거래 ID (confirm_deposit/reject_deposit)',
},
reason: {
type: 'string',
description: '사유 (reject_deposit, block_user)',
},
},
required: ['action'],
},
},
};
export async function executeAdmin(
args: AdminArgs,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
// Verify admin role
const admin = await db
.prepare('SELECT id, role FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number; role: string }>();
if (!admin || admin.role !== 'admin') {
return '관리자 권한이 필요합니다.';
}
switch (args.action) {
case 'block_user':
return await blockUser(db, admin.id, args.target_user_id, args.reason);
case 'unblock_user':
return await unblockUser(db, admin.id, args.target_user_id);
case 'set_role':
return await setRole(db, admin.id, args.target_user_id, args.role);
case 'broadcast':
return await broadcast(env, db, admin.id, args.message);
case 'confirm_deposit':
return await confirmDeposit(db, admin.id, args.transaction_id);
case 'reject_deposit':
return await rejectDeposit(db, admin.id, args.transaction_id, args.reason);
case 'list_pending':
return await listPending(db);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Admin tool error', error as Error, { action: args.action });
return '관리자 작업 중 오류가 발생했습니다.';
}
}
async function blockUser(
db: D1Database,
adminId: number,
targetUserId?: string,
reason?: string
): Promise<string> {
if (!targetUserId) return '대상 사용자 ID를 지정해주세요.';
const result = await db
.prepare(
`UPDATE users SET is_blocked = 1, blocked_reason = ?, updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = ? RETURNING id, username`
)
.bind(reason ?? null, targetUserId)
.first<{ id: number; username: string | null }>();
if (!result) return '사용자를 찾을 수 없습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'block_user',
resourceType: 'user',
resourceId: targetUserId,
details: reason ?? null,
result: 'success',
});
const name = result.username ?? targetUserId;
return `사용자 ${name}이(가) 차단되었습니다.${reason ? ` 사유: ${reason}` : ''}`;
}
async function unblockUser(
db: D1Database,
adminId: number,
targetUserId?: string
): Promise<string> {
if (!targetUserId) return '대상 사용자 ID를 지정해주세요.';
const result = await db
.prepare(
`UPDATE users SET is_blocked = 0, blocked_reason = NULL, updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = ? RETURNING id, username`
)
.bind(targetUserId)
.first<{ id: number; username: string | null }>();
if (!result) return '사용자를 찾을 수 없습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'unblock_user',
resourceType: 'user',
resourceId: targetUserId,
result: 'success',
});
const name = result.username ?? targetUserId;
return `사용자 ${name}의 차단이 해제되었습니다.`;
}
async function setRole(
db: D1Database,
adminId: number,
targetUserId?: string,
role?: UserRole
): Promise<string> {
if (!targetUserId) return '대상 사용자 ID를 지정해주세요.';
if (!role) return '설정할 역할을 지정해주세요.';
const result = await db
.prepare(
`UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP
WHERE telegram_id = ? RETURNING id, username`
)
.bind(role, targetUserId)
.first<{ id: number; username: string | null }>();
if (!result) return '사용자를 찾을 수 없습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'set_role',
resourceType: 'user',
resourceId: targetUserId,
details: `role: ${role}`,
result: 'success',
});
const name = result.username ?? targetUserId;
return `사용자 ${name}의 역할이 ${role}(으)로 변경되었습니다.`;
}
async function broadcast(
env?: Env,
db?: D1Database,
adminId?: number,
message?: string
): Promise<string> {
if (!env || !db || !adminId) return '환경 설정이 올바르지 않습니다.';
if (!message) return '공지 메시지를 입력해주세요.';
const { notifyAdmins } = await import('../services/notification');
await notifyAdmins(env, `[공지] ${message}`);
await createAuditLog(db, {
actorId: adminId,
action: 'broadcast',
resourceType: 'notification',
details: message,
result: 'success',
});
return '공지가 발송되었습니다.';
}
async function confirmDeposit(
db: D1Database,
adminId: number,
transactionId?: number
): Promise<string> {
if (!transactionId) return '거래 ID를 지정해주세요.';
return executeWithOptimisticLock(db, async () => {
const tx = await db
.prepare(
'SELECT id, user_id, amount, status FROM transactions WHERE id = ? AND type = \'deposit\''
)
.bind(transactionId)
.first<{ id: number; user_id: number; amount: number; status: string }>();
if (!tx) return '거래를 찾을 수 없습니다.';
if (tx.status !== 'pending') return `이미 처리된 거래입니다 (상태: ${tx.status}).`;
// Get current wallet version
const wallet = await db
.prepare('SELECT id, balance, version FROM wallets WHERE user_id = ?')
.bind(tx.user_id)
.first<{ id: number; balance: number; version: number }>();
if (!wallet) return '사용자의 지갑을 찾을 수 없습니다.';
// Update with optimistic lock
const updateResult = await db
.prepare(
`UPDATE wallets SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND version = ?`
)
.bind(tx.amount, tx.user_id, wallet.version)
.run();
if (!updateResult.meta.changes || updateResult.meta.changes === 0) {
throw new OptimisticLockError('Wallet version conflict');
}
// Confirm transaction
await db
.prepare(
`UPDATE transactions SET status = 'confirmed', confirmed_by = ?, confirmed_at = CURRENT_TIMESTAMP
WHERE id = ?`
)
.bind(adminId, transactionId)
.run();
await createAuditLog(db, {
actorId: adminId,
action: 'confirm_deposit',
resourceType: 'transaction',
resourceId: String(transactionId),
details: `amount: ${tx.amount}`,
result: 'success',
});
const newBalance = wallet.balance + tx.amount;
return `입금 #${transactionId} 승인 완료.\n금액: ${tx.amount.toLocaleString()}\n새 잔액: ${newBalance.toLocaleString()}`;
});
}
async function rejectDeposit(
db: D1Database,
adminId: number,
transactionId?: number,
reason?: string
): Promise<string> {
if (!transactionId) return '거래 ID를 지정해주세요.';
const result = await db
.prepare(
`UPDATE transactions SET status = 'rejected', confirmed_by = ?, confirmed_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'pending' AND type = 'deposit'
RETURNING id, amount`
)
.bind(adminId, transactionId)
.first<{ id: number; amount: number }>();
if (!result) return '처리할 수 없는 거래입니다. 대기 중인 입금 거래만 거부할 수 있습니다.';
await createAuditLog(db, {
actorId: adminId,
action: 'reject_deposit',
resourceType: 'transaction',
resourceId: String(transactionId),
details: reason ?? null,
result: 'success',
});
return `입금 #${transactionId} 거부 완료.\n금액: ${result.amount.toLocaleString()}${reason ? `\n사유: ${reason}` : ''}`;
}
async function listPending(db: D1Database): Promise<string> {
// Pending transactions
const txResult = await db
.prepare(
`SELECT t.id, t.user_id, t.amount, t.depositor_name, t.created_at, u.username, u.telegram_id
FROM transactions t
JOIN users u ON t.user_id = u.id
WHERE t.status = 'pending' AND t.type = 'deposit'
ORDER BY t.created_at DESC LIMIT 20`
)
.all<{
id: number;
user_id: number;
amount: number;
depositor_name: string | null;
created_at: string;
username: string | null;
telegram_id: string;
}>();
// Pending actions
const actionResult = await db
.prepare(
`SELECT pa.id, pa.action_type, pa.target, pa.created_at, u.username, u.telegram_id
FROM pending_actions pa
JOIN users u ON pa.user_id = u.id
WHERE pa.status = 'pending'
ORDER BY pa.created_at DESC LIMIT 20`
)
.all<{
id: number;
action_type: string;
target: string;
created_at: string;
username: string | null;
telegram_id: string;
}>();
const parts: string[] = [];
if (txResult.results.length > 0) {
const lines = txResult.results.map((t) => {
const name = t.username ?? t.telegram_id;
const date = t.created_at.split('T')[0];
return ` #${t.id} ${name} ${t.amount.toLocaleString()}원 (${t.depositor_name ?? '-'}) ${date}`;
});
parts.push(`대기 중 입금 (${txResult.results.length}건):\n${lines.join('\n')}`);
} else {
parts.push('대기 중 입금: 없음');
}
if (actionResult.results.length > 0) {
const lines = actionResult.results.map((a) => {
const name = a.username ?? a.telegram_id;
const date = a.created_at.split('T')[0];
return ` #${a.id} [${a.action_type}] ${a.target} by ${name} ${date}`;
});
parts.push(`대기 중 작업 (${actionResult.results.length}건):\n${lines.join('\n')}`);
} else {
parts.push('대기 중 작업: 없음');
}
return parts.join('\n\n');
}

109
src/tools/d2-tool.ts Normal file
View File

@@ -0,0 +1,109 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, RenderD2Args } from '../types';
const logger = createLogger('d2-tool');
export const renderD2Tool: ToolDefinition = {
type: 'function',
function: {
name: 'render_d2',
description:
'D2 다이어그램 렌더링: D2 소스 코드를 이미지(SVG/PNG)로 변환합니다.',
parameters: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'D2 다이어그램 소스 코드',
},
format: {
type: 'string',
enum: ['svg', 'png'],
description: '출력 포맷 (기본: svg)',
},
},
required: ['source'],
},
},
};
export async function executeRenderD2(
args: RenderD2Args,
env?: Env,
_userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!env?.D2_RENDER_URL) {
return 'D2 렌더링 서비스가 설정되지 않았습니다.';
}
if (!env.R2_BUCKET) {
return 'R2 스토리지가 설정되지 않았습니다.';
}
const format = args.format ?? 'svg';
const sourceHash = await hashSource(args.source + format);
// Check cache
if (db) {
const cached = await db
.prepare('SELECT r2_key FROM d2_cache WHERE source_hash = ?')
.bind(sourceHash)
.first<{ r2_key: string }>();
if (cached) {
logger.info('D2 cache hit', { hash: sourceHash });
return `다이어그램이 준비되었습니다: /d2/${cached.r2_key}`;
}
}
// Render via external service
const response = await fetch(env.D2_RENDER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: args.source, format }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('D2 render API failed', undefined, {
status: response.status,
error: errorText,
});
return `다이어그램 렌더링 실패: ${response.statusText}`;
}
const imageData = await response.arrayBuffer();
const contentType = format === 'svg' ? 'image/svg+xml' : 'image/png';
const r2Key = `d2/${sourceHash}.${format}`;
// Store in R2
await env.R2_BUCKET.put(r2Key, imageData, {
httpMetadata: { contentType },
});
// Cache metadata
if (db) {
await db
.prepare(
'INSERT INTO d2_cache (source_hash, r2_key, format) VALUES (?, ?, ?)'
)
.bind(sourceHash, r2Key, format)
.run();
}
logger.info('D2 diagram rendered and cached', { hash: sourceHash, format });
return `다이어그램이 준비되었습니다: /d2/${r2Key}`;
} catch (error) {
logger.error('D2 tool error', error as Error);
return '다이어그램 렌더링 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function hashSource(source: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(source);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}

252
src/tools/domain-tool.ts Normal file
View File

@@ -0,0 +1,252 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, ManageDomainArgs, Domain } from '../types';
const logger = createLogger('domain-tool');
export const manageDomainTool: ToolDefinition = {
type: 'function',
function: {
name: 'manage_domain',
description:
'도메인 관리: 도메인 조회, WHOIS 확인, 네임서버 설정, 가격 확인 등. 사용자의 도메인 정보를 관리합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['check', 'whois', 'list', 'info', 'set_ns', 'price'],
description:
'check: 도메인 등록 가능 여부, whois: WHOIS 조회, list: 보유 도메인 목록, info: 도메인 상세 정보, set_ns: 네임서버 변경, price: 가격 조회',
},
domain: {
type: 'string',
description: '대상 도메인 (예: example.com)',
},
nameservers: {
type: 'array',
items: { type: 'string' },
description: 'set_ns 시 설정할 네임서버 목록',
},
tld: {
type: 'string',
description: 'price 조회 시 TLD (예: com, net, io)',
},
},
required: ['action'],
},
},
};
export async function executeManageDomain(
args: ManageDomainArgs,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'list':
return await listDomains(db, userId);
case 'info':
return await getDomainInfo(db, userId, args.domain);
case 'check':
return await checkDomain(env, args.domain);
case 'whois':
return await whoisDomain(env, args.domain);
case 'price':
return await getDomainPrice(env, args.domain, args.tld);
case 'set_ns':
return await setNameservers(db, userId, args.domain, args.nameservers);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Domain tool error', error as Error, { action: args.action });
return '도메인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function listDomains(
db?: D1Database,
userId?: string
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const result = await db
.prepare(
'SELECT domain, status, expiry_date, auto_renew FROM domains WHERE user_id = ? ORDER BY domain'
)
.bind(user.id)
.all<Pick<Domain, 'domain' | 'status' | 'expiry_date' | 'auto_renew'>>();
if (result.results.length === 0) {
return '보유 중인 도메인이 없습니다.';
}
const statusLabel: Record<string, string> = {
active: 'Active',
expired: 'Expired',
pending: 'Pending',
suspended: 'Suspended',
};
const lines = result.results.map((d) => {
const status = statusLabel[d.status] ?? d.status;
const expiry = d.expiry_date ? d.expiry_date.split('T')[0] : '-';
const renew = d.auto_renew ? 'ON' : 'OFF';
return `- ${d.domain} [${status}] 만료: ${expiry} 자동갱신: ${renew}`;
});
return `보유 도메인 (${result.results.length}개):\n${lines.join('\n')}`;
}
async function getDomainInfo(
db?: D1Database,
userId?: string,
domain?: string
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!domain) return '도메인을 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const d = await db
.prepare('SELECT * FROM domains WHERE user_id = ? AND domain = ?')
.bind(user.id, domain)
.first<Domain>();
if (!d) return `도메인 ${domain}을(를) 찾을 수 없습니다.`;
const ns = d.nameservers ?? '-';
const expiry = d.expiry_date ? d.expiry_date.split('T')[0] : '-';
return [
`도메인: ${d.domain}`,
`상태: ${d.status}`,
`등록기관: ${d.registrar ?? '-'}`,
`네임서버: ${ns}`,
`만료일: ${expiry}`,
`자동갱신: ${d.auto_renew ? 'ON' : 'OFF'}`,
`등록일: ${d.created_at.split('T')[0]}`,
].join('\n');
}
async function checkDomain(env?: Env, domain?: string): Promise<string> {
if (!domain) return '도메인을 지정해주세요.';
if (!env?.WHOIS_API_URL) return 'WHOIS API가 설정되지 않았습니다.';
const response = await fetch(`${env.WHOIS_API_URL}/check?domain=${encodeURIComponent(domain)}`);
if (!response.ok) {
return `도메인 조회 실패: ${response.statusText}`;
}
const data = await response.json() as { available?: boolean; domain?: string };
if (data.available) {
return `${domain}은(는) 등록 가능한 도메인입니다.`;
}
return `${domain}은(는) 이미 등록된 도메인입니다.`;
}
async function whoisDomain(env?: Env, domain?: string): Promise<string> {
if (!domain) return '도메인을 지정해주세요.';
if (!env?.WHOIS_API_URL) return 'WHOIS API가 설정되지 않았습니다.';
const response = await fetch(`${env.WHOIS_API_URL}/whois?domain=${encodeURIComponent(domain)}`);
if (!response.ok) {
return `WHOIS 조회 실패: ${response.statusText}`;
}
const data = await response.json() as {
registrar?: string;
creation_date?: string;
expiration_date?: string;
nameservers?: string[];
status?: string;
};
return [
`WHOIS 정보 - ${domain}`,
`등록기관: ${data.registrar ?? '-'}`,
`등록일: ${data.creation_date ?? '-'}`,
`만료일: ${data.expiration_date ?? '-'}`,
`네임서버: ${data.nameservers?.join(', ') ?? '-'}`,
`상태: ${data.status ?? '-'}`,
].join('\n');
}
async function getDomainPrice(
env?: Env,
domain?: string,
tld?: string
): Promise<string> {
if (!env?.NAMECHEAP_API_URL) return '가격 조회 API가 설정되지 않았습니다.';
const query = domain ?? tld;
if (!query) return '도메인 또는 TLD를 지정해주세요.';
const response = await fetch(
`${env.NAMECHEAP_API_URL}/pricing?domain=${encodeURIComponent(query)}`
);
if (!response.ok) {
return `가격 조회 실패: ${response.statusText}`;
}
const data = await response.json() as {
prices?: Array<{ tld: string; register: number; renew: number; currency: string }>;
};
if (!data.prices || data.prices.length === 0) {
return '가격 정보를 찾을 수 없습니다.';
}
const lines = data.prices.map(
(p) =>
`.${p.tld}: 등록 ${p.register.toLocaleString()}${p.currency} / 갱신 ${p.renew.toLocaleString()}${p.currency}`
);
return `도메인 가격 정보:\n${lines.join('\n')}`;
}
async function setNameservers(
db?: D1Database,
userId?: string,
domain?: string,
nameservers?: string[]
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!domain) return '도메인을 지정해주세요.';
if (!nameservers || nameservers.length === 0) return '네임서버를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const d = await db
.prepare('SELECT id FROM domains WHERE user_id = ? AND domain = ?')
.bind(user.id, domain)
.first<{ id: number }>();
if (!d) return `도메인 ${domain}을(를) 찾을 수 없습니다.`;
// Safety: create pending action instead of direct modification
const { createPendingAction } = await import('../services/pending-actions');
const action = await createPendingAction(db, {
userId: user.id,
actionType: 'set_nameservers',
target: domain,
params: { nameservers },
});
return `네임서버 변경 요청이 등록되었습니다 (요청 #${action.id}).\n관리자 승인 후 적용됩니다.\n\n대상: ${domain}\n네임서버: ${nameservers.join(', ')}`;
}

210
src/tools/index.ts Normal file
View File

@@ -0,0 +1,210 @@
import { z } from 'zod';
import { createLogger } from '../utils/logger';
import { detectToolCategories } from '../utils/patterns';
import type { Env, ToolDefinition } from '../types';
import { manageDomainTool, executeManageDomain } from './domain-tool';
import { manageWalletTool, executeManageWallet } from './wallet-tool';
import { manageServerTool, executeManageServer } from './server-tool';
import { checkServiceTool, executeCheckService } from './service-tool';
import { renderD2Tool, executeRenderD2 } from './d2-tool';
import { adminTool, executeAdmin } from './admin-tool';
import { searchKnowledgeTool, executeSearchKnowledge } from './knowledge-tool';
const logger = createLogger('tools');
// ============================================================================
// Zod Validation Schemas
// ============================================================================
const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,251}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/;
const ManageDomainArgsSchema = z.object({
action: z.enum(['check', 'whois', 'list', 'info', 'set_ns', 'price']),
domain: z.string().max(253).regex(DOMAIN_REGEX, '올바른 도메인 형식이 아닙니다').optional(),
nameservers: z.array(z.string().max(253)).max(10).optional(),
tld: z.string().max(20).optional(),
});
const ManageWalletArgsSchema = z.object({
action: z.enum(['balance', 'account', 'request', 'history', 'cancel']),
depositor_name: z.string().max(100).optional(),
amount: z.number().positive().max(100_000_000).optional(),
transaction_id: z.number().int().positive().optional(),
limit: z.number().int().positive().max(50).optional(),
});
const ManageServerArgsSchema = z.object({
action: z.enum(['list', 'info', 'start', 'stop', 'reboot']),
server_id: z.number().int().positive().optional(),
});
const CheckServiceArgsSchema = z.object({
action: z.enum(['status', 'list']),
service_type: z.enum(['ddos', 'vpn', 'all']).optional(),
service_id: z.number().int().positive().optional(),
});
const RenderD2ArgsSchema = z.object({
source: z.string().min(1).max(10000),
format: z.enum(['svg', 'png']).optional(),
});
const AdminArgsSchema = z.object({
action: z.enum([
'block_user', 'unblock_user', 'set_role', 'broadcast',
'confirm_deposit', 'reject_deposit', 'list_pending',
]),
target_user_id: z.string().max(50).optional(),
role: z.enum(['admin', 'user']).optional(),
message: z.string().max(4000).optional(),
transaction_id: z.number().int().positive().optional(),
reason: z.string().max(500).optional(),
});
const SearchKnowledgeArgsSchema = z.object({
query: z.string().min(1).max(200),
category: z.string().max(50).optional(),
});
// ============================================================================
// Validated Executor Helper
// ============================================================================
function createValidatedExecutor<T extends z.ZodType>(
schema: T,
executor: (data: z.infer<T>, env?: Env, userId?: string, db?: D1Database) => Promise<string>,
toolName: string
) {
return async (
args: Record<string, unknown>,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> => {
const result = schema.safeParse(args);
if (!result.success) {
const issues = result.error.issues.map((issue) => {
if (issue.code === 'invalid_value' && 'values' in issue) {
return `허용된 값: ${(issue.values as string[]).join(', ')}`;
}
return issue.message;
});
logger.error(`Invalid ${toolName} args`, new Error(result.error.message), { args });
return `잘못된 입력: ${issues.join(', ')}`;
}
return executor(result.data, env, userId, db);
};
}
// ============================================================================
// Tool Executor Registry
// ============================================================================
const toolExecutors: Record<
string,
(args: Record<string, unknown>, env?: Env, userId?: string, db?: D1Database) => Promise<string>
> = {
manage_domain: createValidatedExecutor(ManageDomainArgsSchema, executeManageDomain, 'domain'),
manage_wallet: createValidatedExecutor(ManageWalletArgsSchema, executeManageWallet, 'wallet'),
manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'),
check_service: createValidatedExecutor(CheckServiceArgsSchema, executeCheckService, 'service'),
render_d2: createValidatedExecutor(RenderD2ArgsSchema, executeRenderD2, 'd2'),
admin: createValidatedExecutor(AdminArgsSchema, executeAdmin, 'admin'),
search_knowledge: createValidatedExecutor(SearchKnowledgeArgsSchema, executeSearchKnowledge, 'knowledge'),
};
// ============================================================================
// All Tools Array
// ============================================================================
export const tools: ToolDefinition[] = [
manageDomainTool,
manageWalletTool,
manageServerTool,
checkServiceTool,
renderD2Tool,
adminTool,
searchKnowledgeTool,
];
// ============================================================================
// Tool Categories
// ============================================================================
export const TOOL_CATEGORIES: Record<string, string[]> = {
domain: [manageDomainTool.function.name],
billing: [manageWalletTool.function.name],
server: [manageServerTool.function.name],
security: [checkServiceTool.function.name],
asset: [
manageDomainTool.function.name,
manageServerTool.function.name,
checkServiceTool.function.name,
],
troubleshoot: [searchKnowledgeTool.function.name],
onboarding: [searchKnowledgeTool.function.name],
};
// ============================================================================
// Message-Based Tool Selection
// ============================================================================
export function selectToolsForMessage(message: string): ToolDefinition[] {
const detectedCategories = detectToolCategories(message);
// No pattern match: return knowledge search only (token saving)
if (detectedCategories.length === 0) {
logger.info('패턴 매칭 없음 → 지식 검색만 사용 (토큰 절약)');
return [searchKnowledgeTool];
}
const selectedNames = new Set(
detectedCategories.flatMap((cat) => TOOL_CATEGORIES[cat] ?? [])
);
// Always include knowledge search
selectedNames.add(searchKnowledgeTool.function.name);
const selectedTools = tools.filter((t) => selectedNames.has(t.function.name));
logger.info('도구 선택 완료', {
message: message.substring(0, 100),
categories: detectedCategories.join(', '),
selectedTools: selectedTools.map((t) => t.function.name).join(', '),
});
return selectedTools;
}
// ============================================================================
// Tool Execution Dispatcher
// ============================================================================
export async function executeTool(
name: string,
args: Record<string, unknown>,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
const executor = toolExecutors[name];
if (!executor) {
return `알 수 없는 도구: ${name}`;
}
return await executor(args, env, userId, db);
} catch (error) {
logger.error('Tool execution error', error as Error, { name, args });
return '도구 실행 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
// Re-export tool definitions
export { manageDomainTool } from './domain-tool';
export { manageWalletTool } from './wallet-tool';
export { manageServerTool } from './server-tool';
export { checkServiceTool } from './service-tool';
export { renderD2Tool } from './d2-tool';
export { adminTool } from './admin-tool';
export { searchKnowledgeTool } from './knowledge-tool';

View File

@@ -0,0 +1,84 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, KnowledgeArticle } from '../types';
const logger = createLogger('knowledge-tool');
export const searchKnowledgeTool: ToolDefinition = {
type: 'function',
function: {
name: 'search_knowledge',
description:
'지식 베이스 검색: FAQ, 가이드, 매뉴얼 등을 검색합니다.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '검색어',
},
category: {
type: 'string',
description: '카테고리 필터 (예: faq, guide, manual)',
},
},
required: ['query'],
},
},
};
export async function executeSearchKnowledge(
args: { query: string; category?: string },
_env?: Env,
_userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!db) return '데이터베이스 연결 정보가 없습니다.';
const searchTerm = `%${args.query}%`;
let result;
if (args.category) {
result = await db
.prepare(
`SELECT id, category, title, content, tags
FROM knowledge_articles
WHERE is_active = 1 AND category = ?
AND (title LIKE ? OR content LIKE ?)
ORDER BY updated_at DESC
LIMIT 5`
)
.bind(args.category, searchTerm, searchTerm)
.all<Pick<KnowledgeArticle, 'id' | 'category' | 'title' | 'content' | 'tags'>>();
} else {
result = await db
.prepare(
`SELECT id, category, title, content, tags
FROM knowledge_articles
WHERE is_active = 1
AND (title LIKE ? OR content LIKE ?)
ORDER BY updated_at DESC
LIMIT 5`
)
.bind(searchTerm, searchTerm)
.all<Pick<KnowledgeArticle, 'id' | 'category' | 'title' | 'content' | 'tags'>>();
}
if (result.results.length === 0) {
return `"${args.query}"에 대한 검색 결과가 없습니다.`;
}
const articles = result.results.map((a) => {
const tags = a.tags ? ` [${a.tags}]` : '';
// Truncate content for display
const preview =
a.content.length > 200 ? a.content.substring(0, 200) + '...' : a.content;
return `[${a.category}] ${a.title}${tags}\n${preview}`;
});
return `검색 결과 (${result.results.length}건):\n\n${articles.join('\n\n---\n\n')}`;
} catch (error) {
logger.error('Knowledge search error', error as Error, { query: args.query });
return '지식 베이스 검색 중 오류가 발생했습니다.';
}
}

176
src/tools/server-tool.ts Normal file
View File

@@ -0,0 +1,176 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, ManageServerArgs, Server } from '../types';
const logger = createLogger('server-tool');
export const manageServerTool: ToolDefinition = {
type: 'function',
function: {
name: 'manage_server',
description:
'서버 관리: 보유 서버 목록 조회, 서버 상세 정보, 시작/중지/재시작 요청.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['list', 'info', 'start', 'stop', 'reboot'],
description:
'list: 서버 목록, info: 서버 상세 정보, start/stop/reboot: 서버 제어 (관리자 승인 필요)',
},
server_id: {
type: 'number',
description: '서버 ID (info, start, stop, reboot 시 필수)',
},
},
required: ['action'],
},
},
};
export async function executeManageServer(
args: ManageServerArgs,
_env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'list':
return await listServers(db, userId);
case 'info':
return await getServerInfo(db, userId, args.server_id);
case 'start':
case 'stop':
case 'reboot':
return await requestServerAction(db, userId, args.action, args.server_id);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Server tool error', error as Error, { action: args.action });
return '서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function listServers(db?: D1Database, userId?: string): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const result = await db
.prepare(
`SELECT id, label, ip_address, status, provider, spec_label, monthly_price
FROM servers WHERE user_id = ? AND status != 'terminated'
ORDER BY id`
)
.bind(user.id)
.all<Pick<Server, 'id' | 'label' | 'ip_address' | 'status' | 'provider' | 'spec_label' | 'monthly_price'>>();
if (result.results.length === 0) {
return '보유 중인 서버가 없습니다.';
}
const statusLabel: Record<string, string> = {
pending: 'Pending',
provisioning: 'Provisioning',
running: 'Running',
stopped: 'Stopped',
failed: 'Failed',
};
const lines = result.results.map((s) => {
const name = s.label ?? `서버 #${s.id}`;
const status = statusLabel[s.status] ?? s.status;
const ip = s.ip_address ?? '-';
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
return `#${s.id} ${name} [${status}] IP: ${ip} ${price}`;
});
return `보유 서버 (${result.results.length}대):\n${lines.join('\n')}`;
}
async function getServerInfo(
db?: D1Database,
userId?: string,
serverId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!serverId) return '서버 ID를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const s = await db
.prepare('SELECT * FROM servers WHERE id = ? AND user_id = ?')
.bind(serverId, user.id)
.first<Server>();
if (!s) return `서버 #${serverId}을(를) 찾을 수 없습니다.`;
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
const provisioned = s.provisioned_at ? s.provisioned_at.split('T')[0] : '-';
const expires = s.expires_at ? s.expires_at.split('T')[0] : '-';
return [
`서버 #${s.id} 상세 정보`,
`이름: ${s.label ?? '-'}`,
`상태: ${s.status}`,
`제공업체: ${s.provider}`,
`IP: ${s.ip_address ?? '-'}`,
`리전: ${s.region ?? '-'}`,
`스펙: ${s.spec_label ?? '-'}`,
`이미지: ${s.image ?? '-'}`,
`월 요금: ${price}`,
`프로비저닝일: ${provisioned}`,
`만료일: ${expires}`,
].join('\n');
}
async function requestServerAction(
db: D1Database | undefined,
userId: string | undefined,
action: 'start' | 'stop' | 'reboot',
serverId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!serverId) return '서버 ID를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const server = await db
.prepare('SELECT id, label, status FROM servers WHERE id = ? AND user_id = ?')
.bind(serverId, user.id)
.first<{ id: number; label: string | null; status: string }>();
if (!server) return `서버 #${serverId}을(를) 찾을 수 없습니다.`;
const actionLabel: Record<string, string> = {
start: '시작',
stop: '중지',
reboot: '재시작',
};
// Safety: create pending action instead of direct execution
const { createPendingAction } = await import('../services/pending-actions');
const pending = await createPendingAction(db, {
userId: user.id,
actionType: `server_${action}`,
target: `server:${serverId}`,
params: { serverId, action },
});
const name = server.label ?? `서버 #${server.id}`;
return `${name} ${actionLabel[action]} 요청이 등록되었습니다 (요청 #${pending.id}).\n관리자 승인 후 실행됩니다.`;
}

174
src/tools/service-tool.ts Normal file
View File

@@ -0,0 +1,174 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, CheckServiceArgs, DdosService, VpnService } from '../types';
const logger = createLogger('service-tool');
export const checkServiceTool: ToolDefinition = {
type: 'function',
function: {
name: 'check_service',
description:
'DDoS 방어/VPN 서비스 상태 및 목록 조회.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'list'],
description: 'status: 특정 서비스 상태, list: 서비스 목록',
},
service_type: {
type: 'string',
enum: ['ddos', 'vpn', 'all'],
description: '서비스 유형 (기본: all)',
},
service_id: {
type: 'number',
description: '특정 서비스 ID (status 시)',
},
},
required: ['action'],
},
},
};
export async function executeCheckService(
args: CheckServiceArgs,
_env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'list':
return await listServices(db, userId, args.service_type);
case 'status':
return await getServiceStatus(db, userId, args.service_type, args.service_id);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Service tool error', error as Error, { action: args.action });
return '서비스 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function listServices(
db?: D1Database,
userId?: string,
serviceType?: 'ddos' | 'vpn' | 'all'
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const type = serviceType ?? 'all';
const parts: string[] = [];
if (type === 'ddos' || type === 'all') {
const ddos = await db
.prepare(
'SELECT id, target, protection_level, status, monthly_price, expiry_date FROM services_ddos WHERE user_id = ? ORDER BY id'
)
.bind(user.id)
.all<Pick<DdosService, 'id' | 'target' | 'protection_level' | 'status' | 'monthly_price' | 'expiry_date'>>();
if (ddos.results.length > 0) {
const lines = ddos.results.map((s) => {
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
const expiry = s.expiry_date ? s.expiry_date.split('T')[0] : '-';
return ` #${s.id} ${s.target} [${s.protection_level}] ${s.status} ${price} 만료: ${expiry}`;
});
parts.push(`DDoS 방어 (${ddos.results.length}개):\n${lines.join('\n')}`);
} else {
parts.push('DDoS 방어: 이용 중인 서비스 없음');
}
}
if (type === 'vpn' || type === 'all') {
const vpn = await db
.prepare(
'SELECT id, protocol, status, endpoint, monthly_price, expiry_date FROM services_vpn WHERE user_id = ? ORDER BY id'
)
.bind(user.id)
.all<Pick<VpnService, 'id' | 'protocol' | 'status' | 'endpoint' | 'monthly_price' | 'expiry_date'>>();
if (vpn.results.length > 0) {
const lines = vpn.results.map((s) => {
const price = s.monthly_price ? `${s.monthly_price.toLocaleString()}원/월` : '-';
const expiry = s.expiry_date ? s.expiry_date.split('T')[0] : '-';
return ` #${s.id} ${s.protocol} [${s.status}] ${s.endpoint ?? '-'} ${price} 만료: ${expiry}`;
});
parts.push(`VPN (${vpn.results.length}개):\n${lines.join('\n')}`);
} else {
parts.push('VPN: 이용 중인 서비스 없음');
}
}
return parts.join('\n\n');
}
async function getServiceStatus(
db?: D1Database,
userId?: string,
serviceType?: 'ddos' | 'vpn' | 'all',
serviceId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!serviceId) return '서비스 ID를 지정해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
// Try DDoS first if type not specified or is ddos
if (!serviceType || serviceType === 'ddos' || serviceType === 'all') {
const ddos = await db
.prepare('SELECT * FROM services_ddos WHERE id = ? AND user_id = ?')
.bind(serviceId, user.id)
.first<DdosService>();
if (ddos) {
const price = ddos.monthly_price ? `${ddos.monthly_price.toLocaleString()}원/월` : '-';
const expiry = ddos.expiry_date ? ddos.expiry_date.split('T')[0] : '-';
return [
`DDoS 방어 서비스 #${ddos.id}`,
`대상: ${ddos.target}`,
`방어 레벨: ${ddos.protection_level}`,
`상태: ${ddos.status}`,
`제공업체: ${ddos.provider ?? '-'}`,
`월 요금: ${price}`,
`만료일: ${expiry}`,
].join('\n');
}
}
// Try VPN if type not specified or is vpn
if (!serviceType || serviceType === 'vpn' || serviceType === 'all') {
const vpn = await db
.prepare('SELECT * FROM services_vpn WHERE id = ? AND user_id = ?')
.bind(serviceId, user.id)
.first<VpnService>();
if (vpn) {
const price = vpn.monthly_price ? `${vpn.monthly_price.toLocaleString()}원/월` : '-';
const expiry = vpn.expiry_date ? vpn.expiry_date.split('T')[0] : '-';
return [
`VPN 서비스 #${vpn.id}`,
`프로토콜: ${vpn.protocol}`,
`상태: ${vpn.status}`,
`엔드포인트: ${vpn.endpoint ?? '-'}`,
`월 요금: ${price}`,
`만료일: ${expiry}`,
].join('\n');
}
}
return `서비스 #${serviceId}을(를) 찾을 수 없습니다.`;
}

238
src/tools/wallet-tool.ts Normal file
View File

@@ -0,0 +1,238 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, ManageWalletArgs, Transaction } from '../types';
const logger = createLogger('wallet-tool');
export const manageWalletTool: ToolDefinition = {
type: 'function',
function: {
name: 'manage_wallet',
description:
'예치금/지갑 관리: 잔액 조회, 입금 계좌 안내, 입금 요청, 거래 내역, 요청 취소.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['balance', 'account', 'request', 'history', 'cancel'],
description:
'balance: 잔액 조회, account: 입금 계좌 안내, request: 입금 요청 등록, history: 거래 내역, cancel: 대기 중 요청 취소',
},
depositor_name: {
type: 'string',
description: '입금자명 (request 시 필수)',
},
amount: {
type: 'number',
description: '입금 금액 (request 시 필수)',
},
transaction_id: {
type: 'number',
description: '거래 ID (cancel 시 필수)',
},
limit: {
type: 'number',
description: '조회할 거래 내역 수 (기본 10)',
},
},
required: ['action'],
},
},
};
export async function executeManageWallet(
args: ManageWalletArgs,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> {
try {
switch (args.action) {
case 'balance':
return await getBalance(db, userId);
case 'account':
return getAccountInfo(env);
case 'request':
return await requestDeposit(db, userId, args.depositor_name, args.amount);
case 'history':
return await getHistory(db, userId, args.limit);
case 'cancel':
return await cancelTransaction(db, userId, args.transaction_id);
default:
return `지원하지 않는 작업입니다: ${args.action}`;
}
} catch (error) {
logger.error('Wallet tool error', error as Error, { action: args.action });
return '지갑 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function getBalance(db?: D1Database, userId?: string): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const wallet = await db
.prepare('SELECT balance, currency FROM wallets WHERE user_id = ?')
.bind(user.id)
.first<{ balance: number; currency: string }>();
if (!wallet) {
return '예치금 계정이 없습니다. 입금 요청을 하시면 자동으로 생성됩니다.';
}
return `현재 잔액: ${wallet.balance.toLocaleString()}${wallet.currency}`;
}
function getAccountInfo(env?: Env): string {
const bankName = env?.DEPOSIT_BANK_NAME ?? '-';
const account = env?.DEPOSIT_BANK_ACCOUNT ?? '-';
const holder = env?.DEPOSIT_BANK_HOLDER ?? '-';
return [
'입금 계좌 안내',
`은행: ${bankName}`,
`계좌번호: ${account}`,
`예금주: ${holder}`,
'',
'입금 후 입금 요청을 등록해주시면 빠르게 확인해드립니다.',
].join('\n');
}
async function requestDeposit(
db?: D1Database,
userId?: string,
depositorName?: string,
amount?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!depositorName) return '입금자명을 입력해주세요.';
if (!amount || amount <= 0) return '올바른 금액을 입력해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
// Ensure wallet exists
await db
.prepare(
`INSERT INTO wallets (user_id, balance, currency)
VALUES (?, 0, 'KRW')
ON CONFLICT (user_id) DO NOTHING`
)
.bind(user.id)
.run();
// Create pending deposit transaction
const prefix = depositorName.length >= 2 ? depositorName.substring(0, 2) : depositorName;
const tx = await db
.prepare(
`INSERT INTO transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
VALUES (?, 'deposit', ?, 'pending', ?, ?, '입금 요청')
RETURNING id, created_at`
)
.bind(user.id, amount, depositorName, prefix)
.first<{ id: number; created_at: string }>();
if (!tx) return '입금 요청 등록에 실패했습니다.';
logger.info('Deposit request created', { txId: tx.id, userId, amount });
return [
'입금 요청이 등록되었습니다.',
`요청 번호: #${tx.id}`,
`금액: ${amount.toLocaleString()}`,
`입금자명: ${depositorName}`,
'',
'입금 확인 후 잔액에 반영됩니다.',
].join('\n');
}
async function getHistory(
db?: D1Database,
userId?: string,
limit?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const queryLimit = Math.min(limit ?? 10, 50);
const result = await db
.prepare(
`SELECT id, type, amount, status, description, created_at
FROM transactions WHERE user_id = ?
ORDER BY created_at DESC LIMIT ?`
)
.bind(user.id, queryLimit)
.all<Pick<Transaction, 'id' | 'type' | 'amount' | 'status' | 'description' | 'created_at'>>();
if (result.results.length === 0) {
return '거래 내역이 없습니다.';
}
const typeLabel: Record<string, string> = {
deposit: '입금',
withdrawal: '출금',
refund: '환불',
charge: '차감',
};
const statusLabel: Record<string, string> = {
pending: '대기',
confirmed: '완료',
rejected: '거부',
cancelled: '취소',
};
const lines = result.results.map((t) => {
const type = typeLabel[t.type] ?? t.type;
const status = statusLabel[t.status] ?? t.status;
const date = t.created_at.split('T')[0];
return `#${t.id} [${type}] ${t.amount.toLocaleString()}원 (${status}) ${date}`;
});
return `최근 거래 내역 (${result.results.length}건):\n${lines.join('\n')}`;
}
async function cancelTransaction(
db?: D1Database,
userId?: string,
transactionId?: number
): Promise<string> {
if (!db || !userId) return '데이터베이스 연결 정보가 없습니다.';
if (!transactionId) return '취소할 거래 번호를 입력해주세요.';
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(userId)
.first<{ id: number }>();
if (!user) return '사용자 정보를 찾을 수 없습니다.';
const result = await db
.prepare(
`UPDATE transactions
SET status = 'cancelled'
WHERE id = ? AND user_id = ? AND status = 'pending'
RETURNING id`
)
.bind(transactionId, user.id)
.first<{ id: number }>();
if (!result) {
return '취소할 수 없는 거래입니다. 대기 중인 본인의 거래만 취소할 수 있습니다.';
}
logger.info('Transaction cancelled', { txId: transactionId, userId });
return `거래 #${transactionId}이(가) 취소되었습니다.`;
}

470
src/types.ts Normal file
View File

@@ -0,0 +1,470 @@
// ============================================
// Environment
// ============================================
export interface Env {
DB: D1Database;
AI: Ai;
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
OPENAI_API_KEY?: string;
ADMIN_TELEGRAM_IDS?: string;
ENVIRONMENT?: string;
// Financial
DEPOSIT_BANK_NAME?: string;
DEPOSIT_BANK_ACCOUNT?: string;
DEPOSIT_BANK_HOLDER?: string;
// API URLs
OPENAI_API_BASE?: string;
D2_RENDER_URL?: string;
NAMECHEAP_API_URL?: string;
WHOIS_API_URL?: string;
CLOUD_ORCHESTRATOR_URL?: string;
CLOUD_ORCHESTRATOR?: Fetcher;
// KV Namespaces
RATE_LIMIT_KV: KVNamespace;
SESSION_KV: KVNamespace;
CACHE_KV: KVNamespace;
// R2
R2_BUCKET: R2Bucket;
}
// ============================================
// Telegram Types
// ============================================
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
callback_query?: CallbackQuery;
}
export interface CallbackQuery {
id: string;
from: TelegramUser;
message?: TelegramMessage;
chat_instance: string;
data?: string;
}
export interface TelegramMessage {
message_id: number;
from: TelegramUser;
chat: TelegramChat;
date: number;
text?: string;
}
export interface TelegramUser {
id: number;
is_bot: boolean;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
}
export interface TelegramChat {
id: number;
type: string;
}
// ============================================
// OpenAI Types (Function Calling)
// ============================================
export interface OpenAIToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: OpenAIToolCall[];
tool_call_id?: string;
name?: string;
}
export interface OpenAIChoice {
message: OpenAIMessage;
finish_reason: string;
}
export interface OpenAIAPIResponse {
choices: OpenAIChoice[];
}
export interface ToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
};
}
// ============================================
// Workers AI Types (Fallback)
// ============================================
export type WorkersAIModel =
| "@cf/meta/llama-3.1-8b-instruct"
| "@cf/meta/llama-3.2-3b-instruct";
export interface WorkersAIMessage {
role: "system" | "user" | "assistant";
content: string;
}
// ============================================
// User & Auth Types
// ============================================
export type UserRole = 'admin' | 'user';
export interface User {
id: number;
telegram_id: string;
username: string | null;
first_name: string | null;
role: UserRole;
language_code: string;
context_limit: number;
last_active_at: string | null;
is_blocked: number;
blocked_reason: string | null;
created_at: string;
updated_at: string;
}
// ============================================
// Financial Types
// ============================================
export interface Wallet {
id: number;
user_id: number;
balance: number;
currency: string;
version: number;
created_at: string;
updated_at: string;
}
export type TransactionType = 'deposit' | 'withdrawal' | 'refund' | 'charge';
export type TransactionStatus = 'pending' | 'confirmed' | 'rejected' | 'cancelled';
export interface Transaction {
id: number;
user_id: number;
type: TransactionType;
amount: number;
status: TransactionStatus;
depositor_name: string | null;
depositor_name_prefix: string | null;
description: string | null;
reference_type: string | null;
reference_id: number | null;
confirmed_by: number | null;
confirmed_at: string | null;
created_at: string;
}
export interface BankNotification {
bankName: string;
depositorName: string;
amount: number;
balanceAfter?: number;
transactionTime?: Date;
rawMessage: string;
}
// ============================================
// Asset Types
// ============================================
export type DomainStatus = 'active' | 'expired' | 'pending' | 'suspended';
export interface Domain {
id: number;
user_id: number;
domain: string;
status: DomainStatus;
registrar: string | null;
nameservers: string | null;
auto_renew: number;
expiry_date: string | null;
created_at: string;
updated_at: string;
}
export type ServerStatus = 'pending' | 'provisioning' | 'running' | 'stopped' | 'terminated' | 'failed';
export interface Server {
id: number;
user_id: number;
provider: string;
instance_id: string | null;
label: string | null;
ip_address: string | null;
region: string | null;
spec_label: string | null;
monthly_price: number | null;
status: ServerStatus;
image: string | null;
provisioned_at: string | null;
terminated_at: string | null;
expires_at: string | null;
created_at: string;
updated_at: string;
}
export interface DdosService {
id: number;
user_id: number;
target: string;
protection_level: 'basic' | 'standard' | 'premium';
status: 'active' | 'inactive' | 'suspended';
provider: string | null;
monthly_price: number | null;
expiry_date: string | null;
}
export interface VpnService {
id: number;
user_id: number;
protocol: 'wireguard' | 'openvpn' | 'ipsec';
status: 'active' | 'inactive' | 'suspended';
endpoint: string | null;
monthly_price: number | null;
expiry_date: string | null;
}
// ============================================
// Support Types
// ============================================
export interface Feedback {
id: number;
user_id: number;
session_type: string;
rating: number;
comment: string | null;
created_at: string;
}
export type PendingActionStatus = 'pending' | 'approved' | 'rejected' | 'executed' | 'failed';
export interface PendingAction {
id: number;
user_id: number;
action_type: string;
target: string;
params: string;
status: PendingActionStatus;
approved_by: number | null;
created_at: string;
executed_at: string | null;
}
export interface AuditLog {
id: number;
actor_id: number | null;
action: string;
resource_type: string;
resource_id: string | null;
details: string | null;
result: 'success' | 'failure';
request_id: string | null;
created_at: string;
}
export interface KnowledgeArticle {
id: number;
category: string;
title: string;
content: string;
tags: string | null;
language: string;
is_active: number;
created_at: string;
updated_at: string;
}
// ============================================
// Agent Session Types
// ============================================
export type OnboardingSessionStatus = 'greeting' | 'gathering' | 'suggesting' | 'completed';
export interface OnboardingSession {
user_id: string;
status: OnboardingSessionStatus;
collected_info: {
purpose?: string;
requirements?: string;
budget?: string;
tech_stack?: string[];
};
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
export type TroubleshootSessionStatus = 'gathering' | 'diagnosing' | 'suggesting' | 'escalated' | 'completed';
export interface TroubleshootSession {
user_id: string;
status: TroubleshootSessionStatus;
collected_info: {
category?: string;
symptoms?: string;
environment?: string;
errorMessage?: string;
affected_service?: string;
};
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
escalation_count: number;
created_at: number;
updated_at: number;
expires_at: number;
}
export type AssetSessionStatus = 'idle' | 'viewing' | 'managing' | 'completed';
export interface AssetSession {
user_id: string;
status: AssetSessionStatus;
collected_info: Record<string, unknown>;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
export type BillingSessionStatus = 'collecting_amount' | 'collecting_name' | 'confirming' | 'completed';
export interface BillingSession {
user_id: string;
status: BillingSessionStatus;
collected_info: {
amount?: number;
depositor_name?: string;
};
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
// ============================================
// Tool Argument Types
// ============================================
export interface ManageDomainArgs {
action: 'check' | 'whois' | 'list' | 'info' | 'set_ns' | 'price';
domain?: string;
nameservers?: string[];
tld?: string;
}
export interface ManageWalletArgs {
action: 'balance' | 'account' | 'request' | 'history' | 'cancel';
depositor_name?: string;
amount?: number;
transaction_id?: number;
limit?: number;
}
export interface ManageServerArgs {
action: 'list' | 'info' | 'start' | 'stop' | 'reboot';
server_id?: number;
}
export interface CheckServiceArgs {
action: 'status' | 'list';
service_type?: 'ddos' | 'vpn' | 'all';
service_id?: number;
}
export interface RenderD2Args {
source: string;
format?: 'svg' | 'png';
}
export interface AdminArgs {
action: 'block_user' | 'unblock_user' | 'set_role' | 'broadcast' | 'confirm_deposit' | 'reject_deposit' | 'list_pending';
target_user_id?: string;
role?: UserRole;
message?: string;
transaction_id?: number;
reason?: string;
}
export interface ApproveActionArgs {
action_id: number;
approve: boolean;
reason?: string;
}
// ============================================
// Inline Keyboard Data
// ============================================
export interface FeedbackKeyboardData {
type: 'feedback';
session_type: string;
rating: number;
}
export interface ActionApprovalKeyboardData {
type: 'action_approval';
action_id: number;
approve: boolean;
}
export interface EscalationKeyboardData {
type: 'escalation';
session_id: string;
action: 'accept' | 'reject';
}
export type KeyboardCallbackData =
| FeedbackKeyboardData
| ActionApprovalKeyboardData
| EscalationKeyboardData;
// ============================================
// D2 Rendering
// ============================================
export interface D2RenderRequest {
source: string;
format: 'svg' | 'png';
}
export interface D2RenderResponse {
success: boolean;
image?: ArrayBuffer;
error?: string;
}
// ============================================
// Request Context
// ============================================
export interface RequestContext {
requestId: string;
userId?: string;
startTime: number;
}

26
src/utils/api-urls.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { Env } from '../types';
const DEFAULT_OPENAI_GATEWAY = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai';
const DEFAULT_D2_RENDER_URL = 'http://10.253.100.107:8080';
/**
* OpenAI Chat Completions API URL (AI Gateway 경유)
*/
export function getOpenAIUrl(env: Env): string {
const base = env.OPENAI_API_BASE || DEFAULT_OPENAI_GATEWAY;
return `${base}/chat/completions`;
}
/**
* OpenAI API base URL (chat/completions 제외)
*/
export function getOpenAIBaseUrl(env: Env): string {
return env.OPENAI_API_BASE || DEFAULT_OPENAI_GATEWAY;
}
/**
* D2 diagram rendering service URL
*/
export function getD2RenderUrl(env: Env): string {
return env.D2_RENDER_URL || DEFAULT_D2_RENDER_URL;
}

View File

@@ -0,0 +1,208 @@
/**
* Circuit Breaker pattern implementation
*
* Prevents cascading failures by temporarily blocking requests
* to a failing service, giving it time to recover.
*/
import { metrics } from './metrics';
import { createLogger } from './logger';
const logger = createLogger('circuit-breaker');
export enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}
export interface CircuitBreakerOptions {
/** Number of consecutive failures before opening circuit (default: 5) */
failureThreshold?: number;
/** Time in ms to wait before attempting recovery (default: 60000) */
resetTimeoutMs?: number;
/** Time window in ms for monitoring failures (default: 120000) */
monitoringWindowMs?: number;
/** Service name for metrics (default: 'unknown') */
serviceName?: string;
}
export class CircuitBreakerError extends Error {
constructor(
message: string,
public readonly state: CircuitState
) {
super(message);
this.name = 'CircuitBreakerError';
}
}
interface FailureRecord {
timestamp: number;
error: Error;
}
export class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failures: FailureRecord[] = [];
private openedAt: number | null = null;
private successCount = 0;
private failureCount = 0;
private readonly failureThreshold: number;
private readonly resetTimeoutMs: number;
private readonly monitoringWindowMs: number;
private readonly serviceName: string;
constructor(options?: CircuitBreakerOptions) {
this.failureThreshold = options?.failureThreshold ?? 5;
this.resetTimeoutMs = options?.resetTimeoutMs ?? 60000;
this.monitoringWindowMs = options?.monitoringWindowMs ?? 120000;
this.serviceName = options?.serviceName ?? 'unknown';
logger.info('Initialized', {
serviceName: this.serviceName,
failureThreshold: this.failureThreshold,
resetTimeoutMs: this.resetTimeoutMs,
monitoringWindowMs: this.monitoringWindowMs,
});
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
getState(): CircuitState {
return this.state;
}
getStats() {
const lastFailure = this.failures.length > 0
? this.failures[this.failures.length - 1]
: null;
return {
state: this.state,
failures: this.failures.length,
lastFailureTime: lastFailure ? new Date(lastFailure.timestamp) : undefined,
stats: {
totalRequests: this.successCount + this.failureCount,
totalFailures: this.failureCount,
totalSuccesses: this.successCount,
},
config: {
failureThreshold: this.failureThreshold,
resetTimeoutMs: this.resetTimeoutMs,
monitoringWindowMs: this.monitoringWindowMs,
},
};
}
reset(): void {
logger.info('Manual reset', { service: this.serviceName });
this.state = CircuitState.CLOSED;
this.failures = [];
this.openedAt = null;
this.successCount = 0;
this.failureCount = 0;
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
private cleanupOldFailures(): void {
const cutoff = Date.now() - this.monitoringWindowMs;
this.failures = this.failures.filter(record => record.timestamp > cutoff);
}
private checkResetTimeout(): void {
if (this.state === CircuitState.OPEN && this.openedAt !== null) {
const elapsed = Date.now() - this.openedAt;
if (elapsed >= this.resetTimeoutMs) {
logger.info('Reset timeout reached, transitioning to HALF_OPEN', {
service: this.serviceName,
elapsedMs: elapsed
});
this.state = CircuitState.HALF_OPEN;
metrics.record('circuit_breaker_state', 2, { service: this.serviceName });
}
}
}
private onSuccess(): void {
this.successCount++;
if (this.state === CircuitState.HALF_OPEN) {
logger.info('Half-open test succeeded, closing circuit', {
service: this.serviceName
});
this.state = CircuitState.CLOSED;
this.failures = [];
this.openedAt = null;
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
}
private onFailure(error: Error): void {
this.failureCount++;
const now = Date.now();
this.failures.push({ timestamp: now, error });
this.cleanupOldFailures();
if (this.state === CircuitState.HALF_OPEN) {
logger.warn('Half-open test failed, reopening circuit', {
service: this.serviceName,
error: error.message
});
this.state = CircuitState.OPEN;
this.openedAt = now;
metrics.record('circuit_breaker_state', 1, { service: this.serviceName });
return;
}
if (this.state === CircuitState.CLOSED) {
if (this.failures.length >= this.failureThreshold) {
logger.warn('Failure threshold exceeded, opening circuit', {
service: this.serviceName,
failureThreshold: this.failureThreshold,
currentFailures: this.failures.length
});
this.state = CircuitState.OPEN;
this.openedAt = now;
metrics.record('circuit_breaker_state', 1, { service: this.serviceName });
}
}
}
/**
* Execute a function through the circuit breaker
*/
async execute<T>(fn: () => Promise<T>): Promise<T> {
this.checkResetTimeout();
if (this.state === CircuitState.OPEN) {
logger.warn('Request blocked - circuit is OPEN', {
service: this.serviceName
});
throw new CircuitBreakerError(
'Circuit breaker is open - service unavailable',
this.state
);
}
metrics.increment('api_call_count', { service: this.serviceName });
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
metrics.increment('api_error_count', { service: this.serviceName });
const err = error instanceof Error ? error : new Error(String(error));
this.onFailure(err);
logger.error('Operation failed', err, {
service: this.serviceName,
failures: this.failures.length,
threshold: this.failureThreshold
});
throw err;
}
}
}

View File

@@ -0,0 +1,97 @@
import { z } from 'zod/v4';
import { createLogger } from './logger';
const logger = createLogger('env-validation');
/**
* Environment variable schema with validation rules
*/
export const EnvSchema = z.object({
// Required secrets
BOT_TOKEN: z.string().min(1, 'BOT_TOKEN is required'),
WEBHOOK_SECRET: z.string().min(10, 'WEBHOOK_SECRET must be at least 10 characters'),
// Optional secrets
OPENAI_API_KEY: z.string().optional(),
ADMIN_TELEGRAM_IDS: z.string().optional(),
// Financial config (optional)
DEPOSIT_BANK_NAME: z.string().optional(),
DEPOSIT_BANK_ACCOUNT: z.string().optional(),
DEPOSIT_BANK_HOLDER: z.string().optional(),
// Configuration with defaults
ENVIRONMENT: z.enum(['development', 'production']).default('production'),
// API URLs (optional, have defaults in wrangler.toml)
OPENAI_API_BASE: z.string().url().optional(),
D2_RENDER_URL: z.string().url().optional(),
NAMECHEAP_API_URL: z.string().url().optional(),
WHOIS_API_URL: z.string().url().optional(),
CLOUD_ORCHESTRATOR_URL: z.string().url().optional(),
});
export type ValidatedEnv = z.infer<typeof EnvSchema>;
export interface EnvValidationResult {
success: boolean;
errors: string[];
warnings: string[];
}
/**
* Validate environment variables
* Call this early in worker initialization
*/
export function validateEnv(env: Record<string, unknown>): EnvValidationResult {
const result: EnvValidationResult = {
success: true,
errors: [],
warnings: [],
};
const parsed = EnvSchema.safeParse(env);
if (!parsed.success) {
result.success = false;
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
result.errors.push(`${path}: ${issue.message}`);
}
}
// Warnings for recommended but optional vars
if (!env.OPENAI_API_KEY) {
result.warnings.push('OPENAI_API_KEY not set - will use Workers AI fallback');
}
if (!env.ADMIN_TELEGRAM_IDS) {
result.warnings.push('ADMIN_TELEGRAM_IDS not set - admin notifications disabled');
}
if (!env.DEPOSIT_BANK_NAME || !env.DEPOSIT_BANK_ACCOUNT) {
result.warnings.push('Bank deposit info not fully configured - deposit feature limited');
}
if (result.errors.length > 0) {
logger.error('Environment validation failed', new Error('Invalid configuration'), {
errors: result.errors,
});
}
if (result.warnings.length > 0) {
logger.warn('Environment validation warnings', { warnings: result.warnings });
}
return result;
}
/**
* Quick check for critical env vars - throws on failure
*/
export function requireEnv(env: Record<string, unknown>, keys: string[]): void {
const missing = keys.filter(key => !env[key]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
}

236
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,236 @@
/**
* 구조화된 로깅 유틸리티
*
* Cloudflare Workers 환경에서 JSON 기반 로깅을 지원하며,
* 프로덕션 환경에서는 구조화된 JSON 로그를,
* 개발 환경에서는 읽기 쉬운 포맷을 제공합니다.
*
* @module logger
*/
import type { Env } from '../types';
/**
* 로그 레벨 열거형
*
* 우선순위: DEBUG < INFO < WARN < ERROR < FATAL
*/
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
FATAL = 'FATAL',
}
export type LogContextValue =
| string
| number
| boolean
| null
| undefined
| unknown
| LogContextValue[]
| { [key: string]: unknown };
export type LogContext = Record<string, unknown>;
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
service?: string;
context?: LogContext;
error?: {
name: string;
message: string;
stack?: string;
};
userId?: string;
duration?: number;
}
export interface LoggerOptions {
minLevel?: LogLevel;
environment?: 'production' | 'development';
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARN]: 2,
[LogLevel.ERROR]: 3,
[LogLevel.FATAL]: 4,
};
const LOG_LEVEL_EMOJI: Record<LogLevel, string> = {
[LogLevel.DEBUG]: '🔍',
[LogLevel.INFO]: '',
[LogLevel.WARN]: '⚠️',
[LogLevel.ERROR]: '❌',
[LogLevel.FATAL]: '💀',
};
export class Logger {
private minLevel: LogLevel;
private isProduction: boolean;
constructor(
private service: string,
options: LoggerOptions = {}
) {
this.minLevel = options.minLevel || LogLevel.INFO;
this.isProduction = options.environment === 'production';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.minLevel];
}
private write(entry: LogEntry): void {
try {
if (this.isProduction) {
console.log(JSON.stringify(entry));
} else {
const emoji = LOG_LEVEL_EMOJI[entry.level];
const timestamp = entry.timestamp;
const level = (entry.level as string).padEnd(5);
const service = entry.service ? `[${entry.service}]` : '';
const message = entry.message;
let output = `${emoji} [${timestamp}] ${level} ${service} ${message}`;
if (entry.context && Object.keys(entry.context).length > 0) {
output += ` ${JSON.stringify(entry.context)}`;
}
if (entry.error) {
output += `\n Error: ${entry.error.name}: ${entry.error.message}`;
if (entry.error.stack) {
output += `\n Stack: ${entry.error.stack}`;
}
}
if (entry.duration !== undefined) {
output += ` (${entry.duration}ms)`;
}
console.log(output);
}
} catch (error) {
console.error('[Logger] Failed to write log:', error);
console.error('[Logger] Original log entry:', entry);
}
}
private createEntry(
level: LogLevel,
message: string,
context?: LogContext,
error?: Error
): LogEntry {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
service: this.service,
};
if (context) {
entry.context = context;
}
if (error) {
entry.error = {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return entry;
}
debug(message: string, context?: LogContext): void {
if (!this.shouldLog(LogLevel.DEBUG)) return;
this.write(this.createEntry(LogLevel.DEBUG, message, context));
}
info(message: string, context?: LogContext): void {
if (!this.shouldLog(LogLevel.INFO)) return;
this.write(this.createEntry(LogLevel.INFO, message, context));
}
warn(message: string, context?: LogContext): void {
if (!this.shouldLog(LogLevel.WARN)) return;
this.write(this.createEntry(LogLevel.WARN, message, context));
}
error(message: string, error?: Error, context?: LogContext): void {
if (!this.shouldLog(LogLevel.ERROR)) return;
this.write(this.createEntry(LogLevel.ERROR, message, context, error));
}
fatal(message: string, error?: Error, context?: LogContext): void {
if (!this.shouldLog(LogLevel.FATAL)) return;
this.write(this.createEntry(LogLevel.FATAL, message, context, error));
}
startTimer(message?: string, context?: LogContext): () => void {
const startTime = Date.now();
const timerMessage = message || 'Operation completed';
return () => {
const duration = Date.now() - startTime;
const entry = this.createEntry(LogLevel.INFO, timerMessage, context);
entry.duration = duration;
this.write(entry);
};
}
withUser(userId: string): Logger {
const userLogger = new Logger(this.service, {
minLevel: this.minLevel,
environment: this.isProduction ? 'production' : 'development',
});
const originalWrite = userLogger['write'].bind(userLogger);
userLogger['write'] = (entry: LogEntry) => {
entry.userId = userId;
originalWrite(entry);
};
return userLogger;
}
}
export function createLogger(service: string, env?: Partial<Env>): Logger {
const environment =
env && 'ENVIRONMENT' in env && env.ENVIRONMENT === 'production'
? 'production'
: 'development';
return new Logger(service, {
minLevel: LogLevel.INFO,
environment,
});
}
export function createDebugLogger(service: string): Logger {
return new Logger(service, {
minLevel: LogLevel.DEBUG,
environment: 'development',
});
}
/**
* Mask sensitive user ID for GDPR compliance
*
* Shows first 4 characters only, rest replaced with asterisks.
*/
export function maskUserId(userId: string | number | undefined): string {
if (!userId) return 'unknown';
const str = String(userId);
if (str.length <= 4) return '****';
return str.slice(0, 4) + '****';
}

109
src/utils/metrics.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* 메트릭 수집 시스템
*
* API 호출 성능, Circuit Breaker 상태, 에러율 등을 추적하는 메트릭 시스템
* - 메모리 기반 (최근 1000개 메트릭만 유지)
* - Worker 재시작 시 초기화
*/
export type MetricType =
| 'api_call_duration'
| 'api_call_count'
| 'api_error_count'
| 'circuit_breaker_state'
| 'retry_count'
| 'cache_hit_rate';
export interface Metric {
name: MetricType;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
export interface MetricStats {
count: number;
sum: number;
avg: number;
min: number;
max: number;
}
export class MetricsCollector {
private metrics: Metric[] = [];
private readonly maxMetrics = 1000;
increment(metric: MetricType, tags?: Record<string, string>): void {
this.record(metric, 1, tags);
}
record(metric: MetricType, value: number, tags?: Record<string, string>): void {
this.metrics.push({
name: metric,
value,
timestamp: Date.now(),
tags,
});
if (this.metrics.length > this.maxMetrics) {
this.metrics.shift();
}
}
startTimer(metric: MetricType, tags?: Record<string, string>): () => void {
const startTime = Date.now();
return () => {
const duration = Date.now() - startTime;
this.record(metric, duration, tags);
};
}
getMetrics(since?: number): Metric[] {
if (since === undefined) {
return [...this.metrics];
}
return this.metrics.filter(m => m.timestamp >= since);
}
getStats(metric: MetricType, tags?: Record<string, string>): MetricStats {
let filtered = this.metrics.filter(m => m.name === metric);
if (tags) {
filtered = filtered.filter(m => {
if (!m.tags) return false;
for (const key in tags) {
if (tags[key] !== m.tags[key]) {
return false;
}
}
return true;
});
}
if (filtered.length === 0) {
return { count: 0, sum: 0, avg: 0, min: 0, max: 0 };
}
const values = filtered.map(m => m.value);
const sum = values.reduce((a, b) => a + b, 0);
const count = values.length;
const avg = sum / count;
const min = Math.min(...values);
const max = Math.max(...values);
return { count, sum, avg, min, max };
}
reset(): void {
this.metrics = [];
}
size(): number {
return this.metrics.length;
}
}
/**
* 전역 메트릭 인스턴스
*/
export const metrics = new MetricsCollector();

View File

@@ -0,0 +1,85 @@
/**
* Optimistic Locking Utility
*
* Prevents data inconsistencies in financial operations where D1 batch()
* is not a true transaction and partial failures can occur.
*
* Pattern:
* 1. Read current version from wallets
* 2. Perform operations
* 3. UPDATE with version check (WHERE version = ?)
* 4. If version mismatch (changes = 0), throw OptimisticLockError
* 5. Retry with exponential backoff (max 3 attempts)
*/
import { createLogger } from './logger';
const logger = createLogger('optimistic-lock');
interface ErrorWithCapture {
captureStackTrace?: (target: object, constructor?: Function) => void;
}
export class OptimisticLockError extends Error {
constructor(message: string) {
super(message);
this.name = 'OptimisticLockError';
const ErrorConstructor = Error as unknown as ErrorWithCapture;
if (typeof ErrorConstructor.captureStackTrace === 'function') {
ErrorConstructor.captureStackTrace(this, OptimisticLockError);
}
}
}
/**
* Execute operation with optimistic locking and automatic retry
*
* @param _db - D1 Database instance
* @param operation - Async operation to execute (receives attempt number)
* @param maxRetries - Maximum retry attempts (default: 3)
* @returns Promise resolving to operation result
* @throws Error if all retries exhausted or non-OptimisticLockError occurs
*/
export async function executeWithOptimisticLock<T>(
_db: D1Database,
operation: (attempt: number) => Promise<T>,
maxRetries: number = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
logger.info(`Optimistic lock attempt ${attempt}/${maxRetries}`, { attempt });
const result = await operation(attempt);
if (attempt > 1) {
logger.info('Optimistic lock succeeded after retry', { attempt, retriesNeeded: attempt - 1 });
}
return result;
} catch (error) {
if (!(error instanceof OptimisticLockError)) {
logger.error('Non-optimistic-lock error in operation', error as Error, { attempt });
throw error;
}
if (attempt < maxRetries) {
// Exponential backoff: 100ms, 200ms, 400ms
const delayMs = 100 * Math.pow(2, attempt - 1);
logger.warn('Optimistic lock conflict - retrying', {
attempt,
nextRetryIn: `${delayMs}ms`,
error: error.message,
});
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
logger.error('Optimistic lock failed - max retries exhausted', error, {
maxRetries,
finalAttempt: attempt,
});
}
}
}
throw new Error(
`처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (${maxRetries}회 재시도 실패)`
);
}

54
src/utils/patterns.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Centralized pattern detection for keyword matching
*
* Used for:
* - Tool category detection (routing to correct agent)
* - Message classification
*/
// ============================================================================
// Tool Category Patterns
// ============================================================================
export const DOMAIN_PATTERNS = /도메인|네임서버|whois|dns|tld|도메인.*등록|등록.*도메인|nameserver|\b\w+\.(com|net|io|kr|org)\b/i;
export const BILLING_PATTERNS = /입금|충전|잔액|계좌|예치금|송금|돈|결제|요금|payment|billing|wallet|credit|deposit|balance|환불|미납|청구/i;
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr|\d+번\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|#\d+\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|reboot|server/i;
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨|error|problem|down|slow|timeout|crash|접속.*안|연결.*안|불안정/i;
export const ONBOARDING_PATTERNS = /신규|가입|서비스\s*소개|뭐하는|어떤\s*서비스|요금|플랜|가격|plan|pricing|시작하려|처음|어떻게\s*이용|회원가입|시작/i;
export const SECURITY_PATTERNS = /ddos|DDoS|디도스|vpn|VPN|보안|방어|공격|트래픽\s*폭주|서비스\s*마비|봇\s*공격|대역폭\s*공격|방화벽|firewall|ssl|인증서/i;
export const ASSET_PATTERNS = /자산|현황|대시보드|내\s*서버|내\s*도메인|보유|목록|리스트|내\s*서비스|내\s*계정|asset|my\s*server|my\s*domain/i;
// ============================================================================
// Pattern Matching Functions
// ============================================================================
/**
* Check if text matches a given pattern
*/
export function matchesPattern(text: string, pattern: RegExp): boolean {
return pattern.test(text);
}
/**
* Detect tool categories from message text
* @returns Array of matched category strings
*/
export function detectToolCategories(text: string): string[] {
const categories: string[] = [];
if (DOMAIN_PATTERNS.test(text)) categories.push('domain');
if (BILLING_PATTERNS.test(text)) categories.push('billing');
if (SERVER_PATTERNS.test(text)) categories.push('server');
if (TROUBLESHOOT_PATTERNS.test(text)) categories.push('troubleshoot');
if (ONBOARDING_PATTERNS.test(text)) categories.push('onboarding');
if (SECURITY_PATTERNS.test(text)) categories.push('security');
if (ASSET_PATTERNS.test(text)) categories.push('asset');
return categories;
}

144
src/utils/retry.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* Retry utility with exponential backoff and jitter
*/
import { metrics } from './metrics';
import { createLogger } from './logger';
const logger = createLogger('retry');
export interface RetryOptions {
/** Maximum number of retry attempts (default: 3) */
maxRetries?: number;
/** Initial delay in milliseconds before first retry (default: 1000) */
initialDelayMs?: number;
/** Maximum delay cap in milliseconds (default: 10000) */
maxDelayMs?: number;
/** Multiplier for exponential backoff (default: 2) */
backoffMultiplier?: number;
/** Whether to add random jitter to delays (default: true) */
jitter?: boolean;
/** Service name for metrics tracking (optional) */
serviceName?: string;
}
export class RetryError extends Error {
constructor(
message: string,
public readonly attempts: number,
public readonly lastError: Error
) {
super(message);
this.name = 'RetryError';
}
}
function calculateDelay(
attempt: number,
initialDelay: number,
maxDelay: number,
multiplier: number,
useJitter: boolean
): number {
let delay = initialDelay * Math.pow(multiplier, attempt);
delay = Math.min(delay, maxDelay);
if (useJitter) {
const jitterRange = delay * 0.2;
const jitterAmount = Math.random() * jitterRange * 2 - jitterRange;
delay += jitterAmount;
}
return Math.floor(delay);
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Execute a function with retry logic using exponential backoff
*
* @param fn - Async function to execute
* @param options - Retry configuration options
* @returns Promise resolving to the function's result
* @throws RetryError if all attempts fail
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options?: RetryOptions
): Promise<T> {
const {
maxRetries = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
backoffMultiplier = 2,
jitter = true,
serviceName = 'unknown',
} = options || {};
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await fn();
if (attempt > 0) {
logger.info('Success on retry', {
service: serviceName,
attempt: attempt + 1,
totalAttempts: maxRetries + 1
});
}
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxRetries) {
logger.error('All attempts failed', lastError, {
service: serviceName,
totalAttempts: maxRetries + 1
});
throw new RetryError(
`Operation failed after ${maxRetries + 1} attempts: ${lastError.message}`,
maxRetries + 1,
lastError
);
}
if (attempt > 0) {
metrics.increment('retry_count', {
service: serviceName,
attempt: String(attempt),
});
}
const delay = calculateDelay(
attempt,
initialDelayMs,
maxDelayMs,
backoffMultiplier,
jitter
);
logger.warn('Attempt failed, retrying', {
service: serviceName,
attempt: attempt + 1,
totalAttempts: maxRetries + 1,
delayMs: delay,
error: lastError.message
});
await sleep(delay);
}
}
// TypeScript safety: should never be reached
throw new RetryError(
'Unexpected retry logic error',
maxRetries + 1,
lastError!
);
}

View File

@@ -0,0 +1,231 @@
/**
* Session Manager - Generic session CRUD for agents
*
* Eliminates duplicated session management code across agents:
* - Onboarding Agent
* - Troubleshoot Agent
* - Asset Agent
* - Billing Agent
*/
import { createLogger } from './logger';
const logger = createLogger('session-manager');
/**
* Base interface for all agent sessions
*/
export interface BaseSession {
user_id: string;
status: string;
collected_info: Record<string, unknown>;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
created_at: number;
updated_at: number;
expires_at: number;
}
export interface SessionManagerConfig {
tableName: string;
ttlMs: number;
maxMessages: number;
}
/**
* Generic session manager for all agents
* Provides CRUD operations, expiry checking, and message management
*/
export class SessionManager<T extends BaseSession> {
private readonly config: SessionManagerConfig;
constructor(config: SessionManagerConfig) {
this.config = config;
}
/**
* Get session from D1 database
*/
async get(db: D1Database, userId: string): Promise<T | null> {
try {
const now = Date.now();
const result = await db.prepare(
`SELECT * FROM ${this.config.tableName} WHERE user_id = ? AND expires_at > ?`
).bind(userId, now).first();
if (!result) {
return null;
}
const session: T = {
user_id: result.user_id as string,
status: result.status as string,
collected_info: result.collected_info
? JSON.parse(result.collected_info as string)
: {},
messages: result.messages
? JSON.parse(result.messages as string)
: [],
created_at: result.created_at as number,
updated_at: result.updated_at as number,
expires_at: result.expires_at as number,
...this.parseAdditionalFields(result),
} as T;
logger.info('세션 조회 성공', {
userId,
status: session.status,
tableName: this.config.tableName
});
return session;
} catch (error) {
logger.error('세션 조회 실패', error as Error, { userId, tableName: this.config.tableName });
return null;
}
}
/**
* Save session to D1 database (insert or replace)
*/
async save(db: D1Database, session: T): Promise<void> {
try {
const now = Date.now();
session.updated_at = now;
session.expires_at = now + this.config.ttlMs;
const additionalColumns = this.getAdditionalColumns(session);
const additionalColumnNames = Object.keys(additionalColumns);
const additionalColumnValues = Object.values(additionalColumns);
const baseColumns = ['user_id', 'status', 'collected_info', 'messages', 'created_at', 'updated_at', 'expires_at'];
const allColumns = [...baseColumns, ...additionalColumnNames];
const allPlaceholders = [...Array(baseColumns.length).fill('?'), ...Array(additionalColumnNames.length).fill('?')];
const sql = `
INSERT INTO ${this.config.tableName}
(${allColumns.join(', ')})
VALUES (${allPlaceholders.join(', ')})
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
${additionalColumnNames.length > 0 ? ', ' + additionalColumnNames.map(col => `${col} = excluded.${col}`).join(', ') : ''}
`;
const baseValues = [
session.user_id,
session.status,
JSON.stringify(session.collected_info || {}),
JSON.stringify(session.messages || []),
session.created_at || now,
now,
session.expires_at
];
await db.prepare(sql).bind(...baseValues, ...additionalColumnValues).run();
logger.info('세션 저장 성공', {
userId: session.user_id,
status: session.status,
tableName: this.config.tableName
});
} catch (error) {
logger.error('세션 저장 실패', error as Error, { userId: session.user_id, tableName: this.config.tableName });
throw error;
}
}
/**
* Delete session from D1 database
*/
async delete(db: D1Database, userId: string): Promise<void> {
try {
await db.prepare(
`DELETE FROM ${this.config.tableName} WHERE user_id = ?`
).bind(userId).run();
logger.info('세션 삭제 성공', { userId, tableName: this.config.tableName });
} catch (error) {
logger.error('세션 삭제 실패', error as Error, { userId, tableName: this.config.tableName });
}
}
/**
* Check if session exists (without full load)
*/
async has(db: D1Database, userId: string): Promise<boolean> {
try {
const now = Date.now();
const result = await db.prepare(
`SELECT expires_at FROM ${this.config.tableName} WHERE user_id = ? AND expires_at > ?`
).bind(userId, now).first();
return result !== null;
} catch (error) {
logger.error('세션 존재 확인 실패', error as Error, { userId, tableName: this.config.tableName });
return false;
}
}
/**
* Create a new session object
*/
create(userId: string, status: string, additionalFields?: Partial<T>): T {
const now = Date.now();
return {
user_id: userId,
status,
collected_info: {},
messages: [],
created_at: now,
updated_at: now,
expires_at: now + this.config.ttlMs,
...additionalFields,
} as T;
}
isExpired(session: T): boolean {
return session.expires_at < Date.now();
}
/**
* Add message to session with max limit
*/
addMessage(session: T, role: 'user' | 'assistant', content: string): void {
session.messages.push({ role, content });
if (session.messages.length > this.config.maxMessages) {
session.messages = session.messages.slice(-this.config.maxMessages);
logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', {
userId: session.user_id,
maxMessages: this.config.maxMessages,
tableName: this.config.tableName
});
}
}
/**
* Get or create session (convenience method)
*/
async getOrCreate(db: D1Database, userId: string, initialStatus: string): Promise<T> {
const existing = await this.get(db, userId);
if (existing) return existing;
return this.create(userId, initialStatus);
}
/**
* Override in subclasses to parse additional fields from DB result
*/
protected parseAdditionalFields(_result: Record<string, unknown>): Partial<T> {
return {};
}
/**
* Override in subclasses to provide additional columns for saving
*/
protected getAdditionalColumns(_session: T): Record<string, unknown> {
return {};
}
}