import { spawn } from 'child_process'; // --- Interfaces --- interface ChatbotSession { sessionId: string; lastActivity: number; messageCount: number; } interface ClaudeJsonResponse { type: string; subtype: string; cost_usd: number; is_error: boolean; duration_ms: number; num_turns: number; result: string; session_id: string; } // --- Configuration --- const CHATBOT_ENABLED = () => process.env.CHATBOT_ENABLED !== 'false'; const CHATBOT_CWD = () => process.env.CHATBOT_CWD || '/home/fitcrm'; const CHATBOT_MAX_TURNS = () => process.env.CHATBOT_MAX_TURNS || '3'; const CHATBOT_TIMEOUT = () => parseInt(process.env.CHATBOT_TIMEOUT || '120000'); const CHATBOT_SESSION_TIMEOUT = () => parseInt(process.env.CHATBOT_SESSION_TIMEOUT || '1800000'); // --- State --- const chatbotSessions = new Map(); const activeLocks = new Set(); let cleanupTimer: NodeJS.Timeout | null = null; // --- Public API --- export function isChatbotEnabled(): boolean { return CHATBOT_ENABLED(); } export function initChatbot(): void { if (cleanupTimer) clearInterval(cleanupTimer); cleanupTimer = setInterval(() => { const now = Date.now(); const timeout = CHATBOT_SESSION_TIMEOUT(); for (const [chatId, session] of chatbotSessions.entries()) { if (now - session.lastActivity > timeout) { chatbotSessions.delete(chatId); console.log(`[Chatbot] Expired session for chat ${chatId}`); } } }, 5 * 60 * 1000); console.log(`[Chatbot] Initialized (enabled: ${CHATBOT_ENABLED()}, cwd: ${CHATBOT_CWD()}, maxTurns: ${CHATBOT_MAX_TURNS()})`); } export async function handleChatbotMessage( chatId: string, message: string, sendTyping: () => Promise, sendReply: (text: string, parseMode?: string) => Promise ): Promise { if (activeLocks.has(chatId)) { await sendReply('⏳ Подождите, обрабатываю предыдущее сообщение...'); return; } activeLocks.add(chatId); const typingInterval = setInterval(async () => { try { await sendTyping(); } catch { /* ignore */ } }, 4000); try { await sendTyping(); const session = chatbotSessions.get(chatId); const isExpired = session && (Date.now() - session.lastActivity > CHATBOT_SESSION_TIMEOUT()); const resumeId = session && !isExpired ? session.sessionId : undefined; const response = await executeWithRetry(chatId, message, resumeId); chatbotSessions.set(chatId, { sessionId: response.session_id, lastActivity: Date.now(), messageCount: (session && !isExpired ? session.messageCount : 0) + 1, }); const resultText = response.result || '(пустой ответ)'; const chunks = splitMessage(resultText); for (const chunk of chunks) { await safeSendReply(sendReply, chunk); } } catch (error: any) { console.error('[Chatbot] Error:', error.message); await safeSendReply(sendReply, `❌ Ошибка: ${error.message}`); } finally { clearInterval(typingInterval); activeLocks.delete(chatId); } } export function resetChatbotSession(chatId: string): boolean { return chatbotSessions.delete(chatId); } export function getChatbotStatus(chatId: string): { enabled: boolean; hasSession: boolean; messageCount: number; sessionAge: number | null; } { const session = chatbotSessions.get(chatId); return { enabled: CHATBOT_ENABLED(), hasSession: !!session, messageCount: session?.messageCount || 0, sessionAge: session ? Math.floor((Date.now() - session.lastActivity) / 60000) : null, }; } // --- Internal --- function executeClaudeCli(message: string, resumeSessionId?: string): Promise { return new Promise((resolve, reject) => { const args: string[] = [ '-p', message, '--output-format', 'json', '--max-turns', CHATBOT_MAX_TURNS(), ]; if (resumeSessionId) { args.push('--resume', resumeSessionId); } // Clean env: remove all Claude Code session vars const env = { ...process.env }; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; delete env.CLAUDE_SPAWNED; delete env.INNERVOICE_SPAWNED; console.log(`[Chatbot] Spawning: claude ${args.map((a, i) => i === 1 ? `"${a.substring(0, 40)}..."` : a).join(' ')}`); const child = spawn('claude', args, { cwd: CHATBOT_CWD(), env, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); // Timeout handler const timer = setTimeout(() => { console.error(`[Chatbot] Timeout after ${CHATBOT_TIMEOUT()}ms, killing process`); child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 5000); }, CHATBOT_TIMEOUT()); child.on('error', (error) => { clearTimeout(timer); console.error(`[Chatbot] Spawn error: ${error.message}`); reject(new Error(`Failed to start claude: ${error.message}`)); }); child.on('close', (code) => { clearTimeout(timer); console.log(`[Chatbot] Process exited with code ${code}`); if (stderr.trim()) { console.error(`[Chatbot] stderr: ${stderr.substring(0, 500)}`); } if (stdout.trim()) { console.log(`[Chatbot] stdout (first 300): ${stdout.substring(0, 300)}`); } else { console.log(`[Chatbot] stdout: (empty)`); } // Try to parse response regardless of exit code if (stdout.trim()) { try { const parsed = parseClaudeResponse(stdout); if (parsed.is_error) { reject(new Error(parsed.result || 'Claude returned an error')); } else { resolve(parsed); } return; } catch (parseErr: any) { console.error(`[Chatbot] Parse error: ${parseErr.message}`); } } if (code !== 0) { const errDetail = stderr.trim() || stdout.trim() || `exit code ${code}`; reject(new Error(`Claude CLI failed: ${errDetail.substring(0, 200)}`)); } else { reject(new Error('Empty response from Claude CLI')); } }); }); } async function executeWithRetry( chatId: string, message: string, resumeId?: string ): Promise { try { return await executeClaudeCli(message, resumeId); } catch (error) { if (resumeId) { console.log(`[Chatbot] Resume failed for ${chatId}, retrying fresh`); chatbotSessions.delete(chatId); return await executeClaudeCli(message, undefined); } throw error; } } function parseClaudeResponse(stdout: string): ClaudeJsonResponse { const trimmed = stdout.trim(); // Try the whole output as a single JSON try { return JSON.parse(trimmed); } catch { /* continue */ } // Try each line (may have streaming JSON lines) const lines = trimmed.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (!line) continue; try { const parsed = JSON.parse(line); if (parsed.type === 'result') return parsed; } catch { continue; } } // Last resort: find result JSON anywhere in output const match = trimmed.match(/\{[^]*?"type"\s*:\s*"result"[^]*?\}/); if (match) { try { return JSON.parse(match[0]); } catch { /* continue */ } } throw new Error(`No valid JSON result in output (${trimmed.substring(0, 150)})`); } function splitMessage(text: string, maxLength: number = 4000): string[] { if (text.length <= maxLength) return [text]; const chunks: string[] = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= maxLength) { chunks.push(remaining); break; } let splitIdx = remaining.lastIndexOf('\n\n', maxLength); if (splitIdx === -1 || splitIdx < maxLength * 0.3) { splitIdx = remaining.lastIndexOf('\n', maxLength); } if (splitIdx === -1 || splitIdx < maxLength * 0.3) { splitIdx = remaining.lastIndexOf(' ', maxLength); } if (splitIdx === -1 || splitIdx < maxLength * 0.3) { splitIdx = maxLength; } chunks.push(remaining.substring(0, splitIdx)); remaining = remaining.substring(splitIdx).trimStart(); } return chunks.slice(0, 5); } async function safeSendReply( sendReply: (text: string, parseMode?: string) => Promise, text: string ): Promise { try { await sendReply(text, 'Markdown'); } catch { try { await sendReply(text); } catch (innerError) { console.error('[Chatbot] Failed to send reply:', innerError); } } }