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>
This commit is contained in:
root
2026-02-19 13:43:53 +00:00
parent e26d94dcc2
commit dc92c7fcf6
2 changed files with 388 additions and 14 deletions

306
src/chatbot.ts Normal file
View File

@@ -0,0 +1,306 @@
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);
}
}
}

View File

@@ -23,10 +23,19 @@ import {
loadProjects, loadProjects,
validateProjectPath validateProjectPath
} from './project-registry.js'; } from './project-registry.js';
import {
handleChatbotMessage,
initChatbot,
isChatbotEnabled,
resetChatbotSession,
getChatbotStatus
} from './chatbot.js';
dotenv.config(); dotenv.config();
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!); const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!, {
handlerTimeout: 300_000, // 5 min for chatbot responses
});
const app = express(); const app = express();
const PORT = parseInt(process.env.PORT || '3456'); const PORT = parseInt(process.env.PORT || '3456');
const HOST = process.env.HOST || 'localhost'; const HOST = process.env.HOST || 'localhost';
@@ -78,6 +87,9 @@ setInterval(() => {
} }
}, 5 * 60 * 1000); // Check every 5 minutes }, 5 * 60 * 1000); // Check every 5 minutes
// Initialize chatbot module
initChatbot();
app.use(express.json()); app.use(express.json());
// Save chat ID to .env file // Save chat ID to .env file
@@ -125,6 +137,10 @@ bot.command('status', async (ctx) => {
bot.command('help', async (ctx) => { bot.command('help', async (ctx) => {
await ctx.reply( await ctx.reply(
'*Claude Telegram Bridge - Commands*\n\n' + '*Claude Telegram Bridge - Commands*\n\n' +
'*Chatbot:*\n' +
'• Send any message — Claude ответит как чат-бот\n' +
'`/chatbot` - Статус чат-бота\n' +
'`/chatreset` - Сброс диалога\n\n' +
'*Session Management:*\n' + '*Session Management:*\n' +
'`/sessions` - List active Claude sessions\n' + '`/sessions` - List active Claude sessions\n' +
'`/queue` - View queued messages\n\n' + '`/queue` - View queued messages\n\n' +
@@ -139,12 +155,11 @@ bot.command('help', async (ctx) => {
'`/status` - Check bridge status\n' + '`/status` - Check bridge status\n' +
'`/test` - Send test notification\n\n' + '`/test` - Send test notification\n\n' +
'*How it works:*\n' + '*How it works:*\n' +
'• Send any message - forwards to active Claude\n' + '• Send any message — chatbot responds via Claude CLI\n' +
'• Target specific project: `ProjectName: message`\n' + '• Target specific project: `ProjectName: message`\n' +
'• Messages show context: 📁 ProjectName [#abc1234]\n' + '• Messages show context: 📁 ProjectName [#abc1234]\n' +
'• Register projects for remote spawning\n' + '• Register projects for remote spawning\n' +
'• Messages queue when projects are offline\n\n' + '• Messages queue when projects are offline',
'More info: See README in bridge folder',
{ parse_mode: 'Markdown' } { parse_mode: 'Markdown' }
); );
}); });
@@ -153,6 +168,31 @@ bot.command('test', async (ctx) => {
await ctx.reply('✅ Test notification received! Bridge is working.'); await ctx.reply('✅ Test notification received! Bridge is working.');
}); });
bot.command('chatbot', async (ctx) => {
const status = getChatbotStatus(ctx.chat.id.toString());
const enabledText = status.enabled ? '✅ Включен' : '⛔ Выключен';
const sessionText = status.hasSession
? `🗣 Активная сессия: ${status.messageCount} сообщений, последнее ${status.sessionAge} мин назад`
: '📭 Нет активной сессии';
await ctx.reply(
`*Chatbot Status*\n\n` +
`${enabledText}\n` +
`${sessionText}\n\n` +
`Сброс диалога: /chatreset`,
{ parse_mode: 'Markdown' }
);
});
bot.command('chatreset', async (ctx) => {
const deleted = resetChatbotSession(ctx.chat.id.toString());
if (deleted) {
await ctx.reply('🔄 Диалог сброшен. Следующее сообщение начнёт новую сессию.');
} else {
await ctx.reply('📭 Нет активной сессии для сброса.');
}
});
bot.command('sessions', async (ctx) => { bot.command('sessions', async (ctx) => {
const sessions = Array.from(activeSessions.values()); const sessions = Array.from(activeSessions.values());
@@ -533,6 +573,9 @@ bot.on('text', async (ctx) => {
// No project specified - check if we should auto-route to last session // No project specified - check if we should auto-route to last session
if (lastMessageSession && activeSessions.has(lastMessageSession)) { if (lastMessageSession && activeSessions.has(lastMessageSession)) {
const session = activeSessions.get(lastMessageSession)!; const session = activeSessions.get(lastMessageSession)!;
const claudeRunning = isClaudeRunning(session.projectName);
if (claudeRunning) {
messageQueue.push({ messageQueue.push({
from, from,
message, message,
@@ -542,15 +585,40 @@ bot.on('text', async (ctx) => {
}); });
await ctx.reply(`💬 Auto-routed to: 📁 *${session.projectName}* [#${lastMessageSession.substring(0, 7)}]`, { parse_mode: 'Markdown' }); await ctx.reply(`💬 Auto-routed to: 📁 *${session.projectName}* [#${lastMessageSession.substring(0, 7)}]`, { parse_mode: 'Markdown' });
console.log(`📥 Auto-routed to ${session.projectName}`); console.log(`📥 Auto-routed to ${session.projectName}`);
return;
}
// Claude not actually running — clean up stale session
console.log(`[CLEANUP] Removing stale lastMessageSession for ${session.projectName}`);
activeSessions.delete(lastMessageSession);
lastMessageSession = null;
}
// No active project session — use chatbot if enabled
if (isChatbotEnabled()) {
const userChatId = ctx.chat.id.toString();
console.log(`🤖 Chatbot handling message from ${from}: "${message.substring(0, 50)}..."`);
// Fire-and-forget: don't block Telegraf's handler
handleChatbotMessage(
userChatId,
message,
async () => {
await ctx.sendChatAction('typing');
},
async (text: string, parseMode?: string) => {
await ctx.reply(text, parseMode ? { parse_mode: parseMode as any } : {});
}
).catch(err => console.error('[Chatbot] Async error:', err));
} else { } else {
// No recent session - add to general message queue // Fallback: queue the message (original behavior)
messageQueue.push({ messageQueue.push({
from, from,
message, message,
timestamp: new Date(), timestamp: new Date(),
read: false read: false
}); });
await ctx.reply('💬 Message received - responding...'); await ctx.reply('💬 Message received - queued for processing.');
console.log('📥 Queued for Claude to process'); console.log('📥 Queued for Claude to process');
} }
}); });