Files
telegram-bot-workers/src/openai-service.ts
kappa ab314b10c4 feat: recreate Server Agent for premium VM management
Session-based agent with OpenAI Function Calling (9 tools).
Follows ddos-agent pattern: execute tools inside loop, feed results back to AI.
Includes D1 migration, session routing in openai-service, and doc updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:40:58 +09:00

538 lines
19 KiB
TypeScript

import type { Env, OpenAIMessage, ToolCall } from './types';
import { tools, selectToolsForMessage, executeTool } from './tools';
import { retryWithBackoff, RetryError } from './utils/retry';
import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
import { createLogger } from './utils/logger';
import { metrics } from './utils/metrics';
import { getOpenAIUrl } from './utils/api-urls';
import { ERROR_MESSAGES } from './constants/messages';
import { processTroubleshootConsultation, hasTroubleshootSession } from './agents/troubleshoot-agent';
import { processDomainConsultation, hasDomainSession } from './agents/domain-agent';
import { processDepositConsultation, hasDepositSession } from './agents/deposit-agent';
import { processDdosConsultation, hasDdosSession } from './agents/ddos-agent';
import { processServerConsultation, hasServerSession } from './agents/server-agent';
const logger = createLogger('openai');
// 사용자 메시지에서 저장할 정보 추출 (패턴 기반)
const SAVEABLE_PATTERNS = [
// 회사/직장
/(?:나|저)?\s*(?:는|은)?\s*([가-힣A-Za-z0-9]+)(?:에서|에)\s*(?:일해|일하고|근무|다녀)/,
// 기술/언어 공부
/(?:요즘|지금|현재)?\s*([가-힣A-Za-z0-9+#]+)(?:로|을|를)?\s*(?:공부|개발|작업|배우)/,
// 직무/역할
/(?:나|저)?\s*(?:는|은)?\s*([가-힣A-Za-z]+)\s*(?:개발자|엔지니어|디자이너|기획자)/,
// 해외 거주
/([가-힣A-Za-z]+)(?:에서|에)\s*(?:살아|거주|있어)/,
// 서버/인프라 - 클라우드 제공자
/(?:나|저|우리)?\s*(?:는|은)?\s*(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24)\s*(?:사용|쓰|이용)/,
// 서버/인프라 - 서버 수량
/서버\s*(\d+)\s*(?:대|개)|(\d+)\s*(?:대|개)\s*서버/,
// 서버/인프라 - 트래픽/사용자 규모
/(?:트래픽|DAU|MAU|동시접속|사용자|유저)\s*(?:가|이)?\s*(?:약|대략|월|일)?\s*(\d+[\d,]*)\s*(?:명|만|천)?/,
// 서버/인프라 - 컨테이너/오케스트레이션
/(?:쿠버네티스|k8s|도커|docker|컨테이너)\s*(?:사용|쓰|운영|돌려)/,
];
function extractSaveableInfo(message: string): string | null {
// 제외 패턴 (이름, 생일, 국내 지역)
if (/(?:이름|생일|서울|부산|대전|대구|광주|인천)/.test(message)) {
return null;
}
for (const pattern of SAVEABLE_PATTERNS) {
if (pattern.test(message)) {
return message.trim();
}
}
return null;
}
// 메모리 카테고리 감지
type MemoryCategory = 'company' | 'tech' | 'role' | 'location' | 'server' | null;
function detectMemoryCategory(content: string): MemoryCategory {
// 회사/직장: ~에서 일해, 근무, 다녀
if (/(?:에서|에)\s*(?:일해|일하고|근무|다녀)/.test(content)) {
return 'company';
}
// 기술/공부: ~공부, 배워, 개발
if (/(?:공부|개발|작업|배우)/.test(content)) {
return 'tech';
}
// 직무: 개발자, 엔지니어, 디자이너, 기획자
if (/(?:개발자|엔지니어|디자이너|기획자)/.test(content)) {
return 'role';
}
// 해외거주: ~에서 살아, 거주
if (/(?:에서|에)\s*(?:살아|거주|있어)/.test(content)) {
return 'location';
}
// 서버/인프라: 클라우드, 서버 수량, 트래픽, 컨테이너
if (/(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24|서버\s*\d|트래픽|DAU|MAU|동시접속|쿠버네티스|k8s|도커|docker|컨테이너)/i.test(content)) {
return 'server';
}
return null;
}
// 백그라운드에서 메모리 저장 (응답에 영향 없음, 카테고리별 덮어쓰기)
async function saveMemorySilently(
db: D1Database | undefined,
telegramUserId: string | undefined,
content: string
): Promise<void> {
if (!db || !telegramUserId) return;
try {
const user = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(telegramUserId)
.first<{ id: number }>();
if (!user) return;
const category = detectMemoryCategory(content);
// 카테고리가 감지되면, 동일 카테고리의 기존 메모리 삭제
if (category) {
const existing = await db
.prepare('SELECT id, content FROM user_memories WHERE user_id = ?')
.bind(user.id)
.all<{ id: number; content: string }>();
if (existing.results && existing.results.length > 0) {
// Collect IDs to delete
const idsToDelete = existing.results
.filter(memory => detectMemoryCategory(memory.content) === category)
.map(memory => memory.id);
if (idsToDelete.length > 0) {
// Single batch delete instead of N individual deletes
const placeholders = idsToDelete.map(() => '?').join(',');
await db.prepare(
`DELETE FROM user_memories WHERE id IN (${placeholders})`
).bind(...idsToDelete).run();
logger.info('Deleted existing memories of same category', {
userId: telegramUserId,
category,
deletedCount: idsToDelete.length
});
}
}
}
// 새 메모리 저장
await db
.prepare('INSERT INTO user_memories (user_id, content) VALUES (?, ?)')
.bind(user.id, content)
.run();
logger.info('Silent memory save', {
userId: telegramUserId,
category: category || 'uncategorized',
contentLength: content.length
});
} catch (error) {
logger.error('Silent memory save failed', error as Error);
}
}
// Circuit Breaker 인스턴스 (전역 공유)
export const openaiCircuitBreaker = new CircuitBreaker({
failureThreshold: 3, // 3회 연속 실패 시 차단
resetTimeoutMs: 30000, // 30초 후 복구 시도
monitoringWindowMs: 60000 // 1분 윈도우
});
interface OpenAIResponse {
choices: {
message: OpenAIMessage;
finish_reason: string;
}[];
}
// OpenAI API 호출 (retry + circuit breaker 적용)
async function callOpenAI(
env: Env,
apiKey: string,
messages: OpenAIMessage[],
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
): Promise<OpenAIResponse> {
const timer = metrics.startTimer('api_call_duration', { service: 'openai' });
try {
return await retryWithBackoff(
async () => {
const response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
tools: selectedTools?.length ? selectedTools : undefined,
tool_choice: selectedTools?.length ? 'auto' : undefined,
max_tokens: 1000,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API 오류: ${response.status} - ${error}`);
}
return response.json();
},
{
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
}
);
} finally {
timer(); // duration 자동 기록 (성공/실패 관계없이)
}
}
// 메인 응답 생성 함수
export async function generateOpenAIResponse(
env: Env,
userMessage: string,
systemPrompt: string,
recentContext: { role: 'user' | 'assistant'; content: string }[],
telegramUserId?: string,
db?: D1Database,
_chatIdStr?: string // Unused after server recommendation removal, kept for API compatibility
): Promise<string> {
// Check if troubleshoot session is active
if (telegramUserId && env.DB) {
try {
const hasTroubleshootSess = await hasTroubleshootSession(env.DB, telegramUserId);
if (hasTroubleshootSess) {
logger.info('트러블슈팅 세션 감지, 트러블슈팅 에이전트로 라우팅', {
userId: telegramUserId
});
const troubleshootResponse = await processTroubleshootConsultation(env.DB, telegramUserId, userMessage, env);
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
if (troubleshootResponse !== '__PASSTHROUGH__') {
return troubleshootResponse;
}
// Continue to normal flow below
}
} catch (error) {
logger.error('Troubleshoot session check failed, continuing with normal flow', error as Error);
// Continue with normal flow if session check fails
}
// Check if domain consultation session is active
try {
const hasDomainSess = await hasDomainSession(env.DB, telegramUserId);
if (hasDomainSess) {
logger.info('도메인 세션 감지, 도메인 에이전트로 라우팅', {
userId: telegramUserId
});
const domainResponse = await processDomainConsultation(env.DB, telegramUserId, userMessage, env);
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
if (domainResponse !== '__PASSTHROUGH__') {
return domainResponse;
}
// Continue to normal flow below
}
} catch (error) {
logger.error('Domain session check failed, continuing with normal flow', error as Error, {
telegramUserId
});
// Continue with normal flow if session check fails
}
// Check for active deposit session
try {
const hasDepositSess = await hasDepositSession(env.DB, telegramUserId);
if (hasDepositSess) {
logger.info('예치금 세션 감지, 예치금 에이전트로 라우팅', {
userId: telegramUserId
});
const depositResponse = await processDepositConsultation(env.DB, telegramUserId, userMessage, env);
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
if (depositResponse !== '__PASSTHROUGH__') {
return depositResponse;
}
// Continue to normal flow below
}
} catch (error) {
logger.error('Deposit session check failed, continuing with normal flow', error as Error, {
telegramUserId
});
// Continue with normal flow if session check fails
}
// Check for active DDoS defense session
try {
const hasDdosSess = await hasDdosSession(env.DB, telegramUserId);
if (hasDdosSess) {
logger.info('DDoS 방어 세션 감지, DDoS 에이전트로 라우팅', {
userId: telegramUserId
});
const ddosResponse = await processDdosConsultation(env.DB, telegramUserId, userMessage, env);
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
if (ddosResponse !== '__PASSTHROUGH__') {
return ddosResponse;
}
// Continue to normal flow below
}
} catch (error) {
logger.error('DDoS session check failed, continuing with normal flow', error as Error, {
telegramUserId
});
// Continue with normal flow if session check fails
}
// Check for active server management session
try {
const hasServerSess = await hasServerSession(env.DB, telegramUserId);
if (hasServerSess) {
logger.info('서버 관리 세션 감지, Server 에이전트로 라우팅', {
userId: telegramUserId
});
const serverResponse = await processServerConsultation(env.DB, telegramUserId, userMessage, env);
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
if (serverResponse !== '__PASSTHROUGH__') {
return serverResponse;
}
// Continue to normal flow below
}
} catch (error) {
logger.error('Server session check failed, continuing with normal flow', error as Error, {
telegramUserId
});
// Continue with normal flow if session check fails
}
}
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
try {
// Circuit Breaker로 전체 실행 감싸기
return await openaiCircuitBreaker.execute(async () => {
const messages: OpenAIMessage[] = [
{ role: 'system', content: systemPrompt },
...recentContext.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user', content: userMessage },
];
// 동적 도구 선택
const selectedTools = selectToolsForMessage(userMessage);
// 첫 번째 호출
let response = await callOpenAI(env, apiKey, messages, selectedTools);
let assistantMessage = response.choices[0].message;
logger.info('tool_calls', {
calls: assistantMessage.tool_calls ? assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments })) : 'none'
});
logger.info('content', { preview: assistantMessage.content?.slice(0, 100) });
// Function Calling 처리 (최대 3회 반복)
let iterations = 0;
while (assistantMessage.tool_calls && iterations < 3) {
iterations++;
// 도구 호출을 병렬 실행
type ToolResult = {
early: true;
result: string;
toolCall: ToolCall;
} | {
early: false;
message: OpenAIMessage;
} | null;
const toolPromises = assistantMessage.tool_calls.map(async (toolCall): Promise<ToolResult> => {
let args: Record<string, unknown>;
try {
args = JSON.parse(toolCall.function.arguments);
} catch (parseError) {
logger.error('Failed to parse tool arguments', parseError as Error, {
toolName: toolCall.function.name,
raw: toolCall.function.arguments.slice(0, 200) // 일부만 로깅
});
return null; // 파싱 실패 시 null 반환
}
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
// Early return 체크 (__KEYBOARD__, __DIRECT__)
if (result.includes('__KEYBOARD__') || result.includes('__DIRECT__')) {
return { early: true as const, result, toolCall };
}
return {
early: false as const,
message: {
role: 'tool' as const,
tool_call_id: toolCall.id,
content: result,
}
};
});
const results = await Promise.all(toolPromises);
// Early return 처리
const earlyResult = results.find((r): r is { early: true; result: string; toolCall: ToolCall } =>
r !== null && r.early === true
);
if (earlyResult) {
if (earlyResult.result.includes('__DIRECT__')) {
// Remove __DIRECT__ marker and everything before it (AI commentary)
const directIndex = earlyResult.result.indexOf('__DIRECT__');
return earlyResult.result.slice(directIndex + '__DIRECT__'.length).trim();
}
return earlyResult.result;
}
// 정상 결과 처리 (null 제외)
const toolResults = results
.filter((r): r is { early: false; message: OpenAIMessage } =>
r !== null && r.early === false
);
// 메모리 저장([SAVED])이 포함되어 있으면 모든 메모리 관련 결과를 숨김
const hasSaveResult = toolResults.some(r => r.message.content === '[SAVED]');
const isMemoryResult = (content: string | null) =>
content === '[SAVED]' || content?.startsWith('📋 저장된 기억');
if (hasSaveResult) {
// 메모리 저장이 있으면: 메모리 관련 도구 호출을 모두 숨기고 다시 호출
const nonMemoryResults = toolResults.filter(r => !isMemoryResult(r.message.content));
if (nonMemoryResults.length === 0) {
// 메모리 작업만 했으면 도구 호출 없이 다시 호출
response = await callOpenAI(env, apiKey, messages, undefined);
assistantMessage = response.choices[0].message;
break;
}
// 메모리 외 다른 도구도 있으면 그것만 포함
const filteredToolCalls = assistantMessage.tool_calls?.filter(tc => {
const matchingResult = toolResults.find(r => r.message.tool_call_id === tc.id);
return matchingResult && !isMemoryResult(matchingResult.message.content);
});
if (filteredToolCalls && filteredToolCalls.length > 0) {
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: filteredToolCalls,
});
messages.push(...nonMemoryResults.map(r => r.message));
}
} else {
// 메모리 저장이 없으면 모든 결과 포함
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
});
messages.push(...toolResults.map(r => r.message));
}
}
// 다시 호출 (도구 없이 응답 생성)
response = await callOpenAI(env, apiKey, messages, undefined);
assistantMessage = response.choices[0].message;
}
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
// 백그라운드 메모리 저장 (AI 응답과 무관하게)
const saveableInfo = extractSaveableInfo(userMessage);
if (saveableInfo) {
// 비동기로 저장, 응답 지연 없음
saveMemorySilently(db, telegramUserId, saveableInfo).catch(err => {
logger.debug('Memory save failed (non-critical)', { error: (err as Error).message });
});
}
return finalResponse;
});
} catch (error) {
// 에러 처리
if (error instanceof CircuitBreakerError) {
logger.error('Circuit breaker open', error as Error);
return ERROR_MESSAGES.SERVICE_UNAVAILABLE;
}
if (error instanceof RetryError) {
logger.error('All retry attempts failed', error as Error);
return ERROR_MESSAGES.AI_RESPONSE_FAILED;
}
// 기타 에러
logger.error('Unexpected error', error as Error);
return ERROR_MESSAGES.UNEXPECTED_ERROR;
}
}
// 프로필 생성용 (도구 없이)
export async function generateProfileWithOpenAI(
env: Env,
prompt: string
): Promise<string> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
try {
// Circuit Breaker로 실행 감싸기
return await openaiCircuitBreaker.execute(async () => {
const response = await callOpenAI(
env,
apiKey,
[{ role: 'user', content: prompt }],
undefined // 도구 없이 호출
);
return response.choices[0].message.content || '프로필 생성 실패';
});
} catch (error) {
// 에러 처리
if (error instanceof CircuitBreakerError) {
logger.error('Profile - Circuit breaker open', error as Error);
return ERROR_MESSAGES.PROFILE_GENERATION_FAILED;
}
if (error instanceof RetryError) {
logger.error('Profile - All retry attempts failed', error as Error);
return ERROR_MESSAGES.PROFILE_GENERATION_FAILED;
}
// 기타 에러
logger.error('Profile - Unexpected error', error as Error);
return ERROR_MESSAGES.PROFILE_GENERATION_UNEXPECTED;
}
}