Добавлен режим чат-бота: каждое текстовое сообщение в 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>
307 lines
8.6 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|