Files
innervoice/src/chatbot.ts
root dc92c7fcf6 feat: chatbot mode — Claude CLI отвечает на сообщения в Telegram
Добавлен режим чат-бота: каждое текстовое сообщение в Telegram
обрабатывается через `claude -p` CLI с контекстом проекта (CLAUDE.md).
Поддержка продолжения диалога через --resume session_id.

Новое:
- src/chatbot.ts: модуль чат-бота (spawn, сессии, retry, split)
- Команды /chatbot (статус) и /chatreset (сброс диалога)
- Конфиг через CHATBOT_* переменные в .env
- Typing-индикатор, блокировка конкурентных запросов
- Безопасная отправка Markdown с fallback на plain text

Изменения в index.ts:
- Интеграция chatbot в bot.on('text') fallback
- handlerTimeout увеличен до 5 мин
- Очистка stale-сессий при отсутствии запущенного Claude

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:43:53 +00:00

307 lines
8.6 KiB
TypeScript

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<string, ChatbotSession>();
const activeLocks = new Set<string>();
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<void>,
sendReply: (text: string, parseMode?: string) => Promise<void>
): Promise<void> {
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<ClaudeJsonResponse> {
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<ClaudeJsonResponse> {
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<void>,
text: string
): Promise<void> {
try {
await sendReply(text, 'Markdown');
} catch {
try {
await sendReply(text);
} catch (innerError) {
console.error('[Chatbot] Failed to send reply:', innerError);
}
}
}