Compare commits

..

5 Commits

Author SHA1 Message Date
root
5cd67c06c6 chore: добавить innervoice/ в .gitignore (старая копия проекта)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:25:45 +00:00
root
df1bbdcc8b feat: контекст чата — бот помнит последние N сообщений
Бот подхватывает последние 20 (настраивается) сообщений из чата
как контекст при ответе. Команда /context для просмотра и настройки.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:11:26 +00:00
root
b3fdd383ac feat: task manager — фоновые задачи без таймаута через /task
Новый модуль task-manager.ts: очередь задач с файловым хранением,
worker loop каждые 5 сек, Claude CLI с --max-turns 50 без таймаута,
Telegram-уведомления по завершении. Команды /task, /task list,
/task N, /task cancel N. HTTP API /tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:03:32 +00:00
root
18fcc06626 fix: осмысленный fallback вместо «пустой ответ», chatbot только по @mention/reply
- Пустой result при error_max_turns → «Принято. Выполнил N действий...»
- Пустой result в остальных случаях → «Принято.»
- Chatbot отвечает только при @упоминании или reply на сообщение бота

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:24:22 +00:00
root
d589e3128d feat: chatbot отвечает только при @упоминании или reply на его сообщение
Бот больше не реагирует на все сообщения в чате.
Отвечает только когда:
- упомянут через @username в тексте
- сообщение является ответом (reply) на сообщение бота

@mention автоматически вырезается из текста перед отправкой в Claude.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:55:29 +00:00
4 changed files with 633 additions and 9 deletions

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ Thumbs.db
# PM2
.pm2/
# Stale project copy
innervoice/

View File

@@ -73,6 +73,15 @@ export async function handleChatbotMessage(
try { await sendTyping(); } catch { /* ignore */ }
}, 4000);
// Send "working on it" notification if Claude takes longer than 15 seconds
let longTaskNotified = false;
const longTaskTimer = setTimeout(async () => {
longTaskNotified = true;
try {
await safeSendReply(sendReply, '⏳ Задача принята, работаю... Ответ может занять несколько минут.');
} catch { /* ignore */ }
}, 15000);
try {
await sendTyping();
@@ -81,6 +90,7 @@ export async function handleChatbotMessage(
const resumeId = session && !isExpired ? session.sessionId : undefined;
const response = await executeWithRetry(chatId, message, resumeId);
clearTimeout(longTaskTimer);
chatbotSessions.set(chatId, {
sessionId: response.session_id,
@@ -88,15 +98,25 @@ export async function handleChatbotMessage(
messageCount: (session && !isExpired ? session.messageCount : 0) + 1,
});
const resultText = response.result || '(пустой ответ)';
let resultText = response.result?.trim();
if (!resultText) {
if (response.subtype === 'error_max_turns') {
resultText = `✅ Принято. Выполнил ${response.num_turns} действий, но не уложился в лимит ходов для текстового ответа. Переформулируйте вопрос или уточните, что именно нужно.`;
} else {
resultText = '✅ Принято.';
}
}
const chunks = splitMessage(resultText);
for (const chunk of chunks) {
await safeSendReply(sendReply, chunk);
}
} catch (error: any) {
clearTimeout(longTaskTimer);
console.error('[Chatbot] Error:', error.message);
await safeSendReply(sendReply, `❌ Ошибка: ${error.message}`);
const isTimeout = error.message?.includes('exit code 143') || error.message?.includes('Timeout');
const hint = isTimeout ? '\n\n💡 Используйте /task для долгих задач без таймаута.' : '';
await safeSendReply(sendReply, `❌ Ошибка: ${error.message}${hint}`);
} finally {
clearInterval(typingInterval);
activeLocks.delete(chatId);
@@ -230,7 +250,7 @@ async function executeWithRetry(
}
}
function parseClaudeResponse(stdout: string): ClaudeJsonResponse {
export function parseClaudeResponse(stdout: string): ClaudeJsonResponse {
const trimmed = stdout.trim();
// Try the whole output as a single JSON
@@ -260,7 +280,7 @@ function parseClaudeResponse(stdout: string): ClaudeJsonResponse {
throw new Error(`No valid JSON result in output (${trimmed.substring(0, 150)})`);
}
function splitMessage(text: string, maxLength: number = 4000): string[] {
export function splitMessage(text: string, maxLength: number = 4000): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];

View File

@@ -28,8 +28,19 @@ import {
initChatbot,
isChatbotEnabled,
resetChatbotSession,
getChatbotStatus
getChatbotStatus,
splitMessage
} from './chatbot.js';
import {
initTaskManager,
createTask,
getTask,
listTasks,
cancelTask,
stopTaskManager,
formatTaskShort,
getTaskStatusEmoji
} from './task-manager.js';
dotenv.config();
@@ -76,6 +87,45 @@ const pendingQuestions = new Map<string, {
// Track the last session that sent a message (for auto-routing replies)
let lastMessageSession: string | null = null;
// Chat history buffer for chatbot context
const CHATBOT_CONTEXT_MESSAGES = () => parseInt(process.env.CHATBOT_CONTEXT_MESSAGES || '20');
interface ChatHistoryEntry {
from: string;
text: string;
timestamp: number;
}
const chatHistory = new Map<string, ChatHistoryEntry[]>();
function addToChatHistory(chatIdKey: string, from: string, text: string): void {
if (!chatHistory.has(chatIdKey)) {
chatHistory.set(chatIdKey, []);
}
const history = chatHistory.get(chatIdKey)!;
history.push({ from, text, timestamp: Date.now() });
// Keep buffer bounded (2x limit to avoid frequent trimming)
const maxSize = CHATBOT_CONTEXT_MESSAGES() * 2;
if (history.length > maxSize) {
history.splice(0, history.length - maxSize);
}
}
function getChatContext(chatIdKey: string, excludeLatest: boolean = true): string {
const limit = CHATBOT_CONTEXT_MESSAGES();
if (limit <= 0) return '';
const history = chatHistory.get(chatIdKey);
if (!history || history.length === 0) return '';
const entries = excludeLatest ? history.slice(0, -1) : history;
const recent = entries.slice(-limit);
if (recent.length === 0) return '';
const lines = recent.map(e => `[${e.from}]: ${e.text}`);
return `Контекст последних ${recent.length} сообщений в чате:\n${lines.join('\n')}\n\n---\nТекущий запрос:\n`;
}
// Clean up expired sessions periodically
setInterval(() => {
const now = Date.now();
@@ -90,6 +140,22 @@ setInterval(() => {
// Initialize chatbot module
initChatbot();
// Initialize task manager with Telegram notification callback
initTaskManager(async (targetChatId: string, text: string) => {
const chunks = splitMessage(text);
for (const chunk of chunks) {
try {
await bot.telegram.sendMessage(targetChatId, chunk, { parse_mode: 'Markdown' });
} catch {
try {
await bot.telegram.sendMessage(targetChatId, chunk);
} catch (err) {
console.error('[TaskManager] Failed to send notification:', err);
}
}
}
});
app.use(express.json());
// Save chat ID to .env file
@@ -138,9 +204,16 @@ bot.command('help', async (ctx) => {
await ctx.reply(
'*Claude Telegram Bridge - Commands*\n\n' +
'*Chatbot:*\n' +
'• Send any message — Claude ответит как чат-бот\n' +
'• @mention или reply — Claude ответит как чат-бот\n' +
'`/chatbot` - Статус чат-бота\n' +
'`/chatreset` - Сброс диалога\n\n' +
'`/chatreset` - Сброс диалога\n' +
'`/context` - Сколько сообщений бот помнит из чата\n' +
'`/context N` - Установить кол-во (0 = выкл)\n\n' +
'*Background Tasks:*\n' +
'`/task <message>` - Создать фоновую задачу (без таймаута)\n' +
'`/task list` - Список задач\n' +
'`/task N` - Статус задачи #N\n' +
'`/task cancel N` - Отменить задачу\n\n' +
'*Session Management:*\n' +
'`/sessions` - List active Claude sessions\n' +
'`/queue` - View queued messages\n\n' +
@@ -193,6 +266,101 @@ bot.command('chatreset', async (ctx) => {
}
});
bot.command('context', async (ctx) => {
const arg = ctx.message.text.split(' ').slice(1)[0];
if (arg !== undefined) {
const num = parseInt(arg);
if (isNaN(num) || num < 0 || num > 100) {
await ctx.reply('❌ Укажите число от 0 до 100. Пример: `/context 20`', { parse_mode: 'Markdown' });
return;
}
process.env.CHATBOT_CONTEXT_MESSAGES = num.toString();
if (num === 0) {
await ctx.reply('🔕 Контекст чата отключён. Бот не будет видеть предыдущие сообщения.');
} else {
await ctx.reply(`✅ Бот будет помнить последние *${num}* сообщений из чата.`, { parse_mode: 'Markdown' });
}
return;
}
const current = CHATBOT_CONTEXT_MESSAGES();
const historySize = chatHistory.get(ctx.chat.id.toString())?.length || 0;
await ctx.reply(
`*Контекст чата*\n\n` +
`📝 Помнит: *${current}* сообщений\n` +
`💬 В буфере: ${historySize} сообщений\n\n` +
`Изменить: \`/context N\` (0 = выкл)`,
{ parse_mode: 'Markdown' }
);
});
bot.command('task', async (ctx) => {
const text = ctx.message.text.replace(/^\/task\s*/, '').trim();
const userChatId = ctx.chat.id.toString();
// /task list
if (text === 'list' || text === '') {
const tasks = await listTasks();
if (tasks.length === 0) {
await ctx.reply('📭 Нет задач.\n\nСоздать: `/task ваше сообщение`', { parse_mode: 'Markdown' });
return;
}
const lines = tasks.map(t => formatTaskShort(t));
await ctx.reply(`*Задачи* (последние ${tasks.length})\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' });
return;
}
// /task cancel N
const cancelMatch = text.match(/^cancel\s+(\d+)$/);
if (cancelMatch) {
const id = parseInt(cancelMatch[1]);
const success = await cancelTask(id);
if (success) {
await ctx.reply(`🚫 Задача #${id} отменена.`);
} else {
await ctx.reply(`Не удалось отменить задачу #${id}. Возможно, она уже завершена.`);
}
return;
}
// /task N — show task details
const idMatch = text.match(/^(\d+)$/);
if (idMatch) {
const id = parseInt(idMatch[1]);
const task = await getTask(id);
if (!task) {
await ctx.reply(`❌ Задача #${id} не найдена.`);
return;
}
const emoji = getTaskStatusEmoji(task.status);
let detail = `${emoji} *Задача #${task.id}*\n\n`;
detail += `*Статус:* ${task.status}\n`;
detail += `*Создана:* ${new Date(task.createdAt).toLocaleString('ru')}\n`;
if (task.startedAt) detail += `*Начата:* ${new Date(task.startedAt).toLocaleString('ru')}\n`;
if (task.completedAt) detail += `*Завершена:* ${new Date(task.completedAt).toLocaleString('ru')}\n`;
if (task.durationMs) detail += `*Длительность:* ${Math.round(task.durationMs / 1000)} сек\n`;
if (task.numTurns) detail += `*Ходы:* ${task.numTurns}\n`;
detail += `\n*Запрос:*\n${task.message.substring(0, 200)}${task.message.length > 200 ? '...' : ''}\n`;
if (task.result) {
const preview = task.result.substring(0, 500);
detail += `\n*Результат:*\n${preview}${task.result.length > 500 ? '...' : ''}`;
}
if (task.error) detail += `\n*Ошибка:* ${task.error}`;
try {
await ctx.reply(detail, { parse_mode: 'Markdown' });
} catch {
await ctx.reply(detail);
}
return;
}
// /task <message> — create new task
const task = await createTask(userChatId, text);
await ctx.reply(`📋 *Задача #${task.id} создана*\n\nВыполняется в фоне. Уведомлю по завершении.`, { parse_mode: 'Markdown' });
});
bot.command('sessions', async (ctx) => {
const sessions = Array.from(activeSessions.values());
@@ -439,6 +607,9 @@ bot.on('text', async (ctx) => {
console.log(`\n📨 Message from ${from}: "${message}"\n`);
// Record message in chat history buffer (for chatbot context)
addToChatHistory(ctx.chat.id.toString(), from, message);
// Check if this is an answer to a pending question
const questionId = Array.from(pendingQuestions.keys())[0];
if (questionId && pendingQuestions.has(questionId)) {
@@ -595,14 +766,46 @@ bot.on('text', async (ctx) => {
}
// No active project session — use chatbot if enabled
// Only respond when bot is mentioned (@username) or message is a reply to bot
if (isChatbotEnabled()) {
const botInfo = bot.botInfo;
const botId = botInfo?.id;
const botUsername = botInfo?.username?.toLowerCase();
// Check if bot is mentioned via @username in message entities
const entities = ctx.message.entities || [];
const isMentioned = botUsername && entities.some(e =>
e.type === 'mention' &&
message.substring(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername}`
);
// Check if message is a reply to bot's own message
const replyTo = ctx.message.reply_to_message;
const isReplyToBot = replyTo?.from?.id === botId;
if (!isMentioned && !isReplyToBot) {
// Not addressed to bot — ignore silently
return;
}
// Strip @mention from message text before sending to Claude
let cleanMessage = message;
if (isMentioned && botUsername) {
cleanMessage = message.replace(new RegExp(`@${botUsername}`, 'gi'), '').trim();
}
if (!cleanMessage) cleanMessage = message;
const userChatId = ctx.chat.id.toString();
console.log(`🤖 Chatbot handling message from ${from}: "${message.substring(0, 50)}..."`);
console.log(`🤖 Chatbot handling message from ${from}: "${cleanMessage.substring(0, 50)}..."`);
// Prepend chat history as context
const context = getChatContext(userChatId);
const messageWithContext = context ? `${context}${cleanMessage}` : cleanMessage;
// Fire-and-forget: don't block Telegraf's handler
handleChatbotMessage(
userChatId,
message,
messageWithContext,
async () => {
await ctx.sendChatAction('typing');
},
@@ -747,6 +950,55 @@ app.get('/queue/summary', async (req, res) => {
}
});
// Task manager endpoints
app.post('/tasks', async (req, res) => {
const { chatId: taskChatId, message } = req.body;
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
try {
const task = await createTask(taskChatId || chatId || 'http', message);
res.json({ success: true, task });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.get('/tasks', async (req, res) => {
try {
const tasks = await listTasks();
res.json({ tasks, count: tasks.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.get('/tasks/:id', async (req, res) => {
try {
const task = await getTask(parseInt(req.params.id));
if (task) {
res.json({ task });
} else {
res.status(404).json({ error: 'Task not found' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.post('/tasks/:id/cancel', async (req, res) => {
try {
const success = await cancelTask(parseInt(req.params.id));
if (success) {
res.json({ success: true });
} else {
res.status(400).json({ error: 'Cannot cancel task (already completed or not found)' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// HTTP endpoint for sending notifications
app.post('/notify', async (req, res) => {
if (!ENABLED) {
@@ -1079,11 +1331,13 @@ app.listen(PORT, HOST, () => {
// Graceful shutdown
process.once('SIGINT', () => {
console.log('\n👋 Shutting down...');
stopTaskManager();
bot.stop('SIGINT');
process.exit(0);
});
process.once('SIGTERM', () => {
stopTaskManager();
bot.stop('SIGTERM');
process.exit(0);
});

347
src/task-manager.ts Normal file
View File

@@ -0,0 +1,347 @@
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { existsSync } from 'fs';
import { parseClaudeResponse, splitMessage } from './chatbot.js';
// --- Interfaces ---
export interface Task {
id: number;
chatId: string;
message: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
createdAt: string;
startedAt?: string;
completedAt?: string;
result?: string;
error?: string;
sessionId?: string;
numTurns?: number;
costUsd?: number;
durationMs?: number;
}
interface TaskStore {
nextId: number;
tasks: Task[];
}
// --- Configuration ---
const TASK_MAX_TURNS = () => process.env.TASK_MAX_TURNS || '50';
const TASK_CWD = () => process.env.TASK_CWD || process.env.CHATBOT_CWD || '/home/fitcrm';
const TASKS_FILE = path.join(process.env.HOME || '~', '.innervoice', 'tasks.json');
const MAX_STORED_TASKS = 50;
// --- State ---
let workerTimer: NodeJS.Timeout | null = null;
let currentProcess: ChildProcess | null = null;
let sendNotification: ((chatId: string, text: string) => Promise<void>) | null = null;
// --- Persistence ---
async function ensureDir(): Promise<void> {
const dir = path.dirname(TASKS_FILE);
if (!existsSync(dir)) {
await fs.mkdir(dir, { recursive: true });
}
}
async function loadStore(): Promise<TaskStore> {
await ensureDir();
if (!existsSync(TASKS_FILE)) {
return { nextId: 1, tasks: [] };
}
try {
const content = await fs.readFile(TASKS_FILE, 'utf-8');
return JSON.parse(content);
} catch {
return { nextId: 1, tasks: [] };
}
}
async function saveStore(store: TaskStore): Promise<void> {
await ensureDir();
// Keep only last MAX_STORED_TASKS
if (store.tasks.length > MAX_STORED_TASKS) {
const active = store.tasks.filter(t => t.status === 'pending' || t.status === 'running');
const finished = store.tasks
.filter(t => t.status !== 'pending' && t.status !== 'running')
.slice(-MAX_STORED_TASKS);
store.tasks = [...active, ...finished].slice(-MAX_STORED_TASKS);
}
await fs.writeFile(TASKS_FILE, JSON.stringify(store, null, 2));
}
// --- Public API ---
export function initTaskManager(
notifyFn: (chatId: string, text: string) => Promise<void>
): void {
sendNotification = notifyFn;
// Recovery: mark stuck 'running' tasks as failed
loadStore().then(async (store) => {
let changed = false;
for (const task of store.tasks) {
if (task.status === 'running') {
task.status = 'failed';
task.error = 'Process restarted';
task.completedAt = new Date().toISOString();
changed = true;
console.log(`[TaskManager] Recovered stuck task #${task.id}`);
}
}
if (changed) await saveStore(store);
});
// Start worker loop
if (workerTimer) clearInterval(workerTimer);
workerTimer = setInterval(workerLoop, 5000);
console.log(`[TaskManager] Initialized (maxTurns: ${TASK_MAX_TURNS()}, cwd: ${TASK_CWD()})`);
}
export async function createTask(chatId: string, message: string): Promise<Task> {
const store = await loadStore();
const task: Task = {
id: store.nextId++,
chatId,
message,
status: 'pending',
createdAt: new Date().toISOString(),
};
store.tasks.push(task);
await saveStore(store);
console.log(`[TaskManager] Created task #${task.id}: "${message.substring(0, 50)}..."`);
return task;
}
export async function getTask(id: number): Promise<Task | undefined> {
const store = await loadStore();
return store.tasks.find(t => t.id === id);
}
export async function listTasks(): Promise<Task[]> {
const store = await loadStore();
return store.tasks.slice(-10).reverse();
}
export async function cancelTask(id: number): Promise<boolean> {
const store = await loadStore();
const task = store.tasks.find(t => t.id === id);
if (!task) return false;
if (task.status === 'pending') {
task.status = 'cancelled';
task.completedAt = new Date().toISOString();
await saveStore(store);
return true;
}
if (task.status === 'running' && currentProcess) {
currentProcess.kill('SIGTERM');
setTimeout(() => { currentProcess?.kill('SIGKILL'); }, 5000);
task.status = 'cancelled';
task.completedAt = new Date().toISOString();
await saveStore(store);
return true;
}
return false;
}
export function stopTaskManager(): void {
if (workerTimer) {
clearInterval(workerTimer);
workerTimer = null;
}
if (currentProcess) {
currentProcess.kill('SIGTERM');
currentProcess = null;
}
console.log('[TaskManager] Stopped');
}
// --- Worker ---
let workerBusy = false;
async function workerLoop(): Promise<void> {
if (workerBusy) return;
const store = await loadStore();
const pending = store.tasks.find(t => t.status === 'pending');
if (!pending) return;
workerBusy = true;
console.log(`[TaskManager] Starting task #${pending.id}: "${pending.message.substring(0, 50)}..."`);
// Mark as running
pending.status = 'running';
pending.startedAt = new Date().toISOString();
await saveStore(store);
try {
const response = await executeTaskCli(pending.message);
// Update task
const freshStore = await loadStore();
const task = freshStore.tasks.find(t => t.id === pending.id);
if (task) {
task.status = 'completed';
task.completedAt = new Date().toISOString();
task.sessionId = response.session_id;
task.numTurns = response.num_turns;
task.costUsd = response.cost_usd;
task.durationMs = response.duration_ms;
let resultText = response.result?.trim();
if (!resultText) {
if (response.subtype === 'error_max_turns') {
resultText = `Выполнил ${response.num_turns} действий, но не уложился в лимит ходов.`;
} else {
resultText = 'Задача выполнена (без текстового ответа).';
}
}
task.result = resultText;
await saveStore(freshStore);
// Notify
const duration = formatDuration(task.durationMs || 0);
const header = `*Задача #${task.id} завершена* (${duration})`;
const body = resultText.length > 3500 ? resultText.substring(0, 3500) + '...' : resultText;
await notify(task.chatId, `${header}\n\n${body}`);
}
} catch (error: any) {
const freshStore = await loadStore();
const task = freshStore.tasks.find(t => t.id === pending.id);
if (task && task.status === 'running') {
task.status = 'failed';
task.completedAt = new Date().toISOString();
task.error = error.message;
await saveStore(freshStore);
await notify(task.chatId, `*Задача #${task.id} провалилась*\n\n${error.message}`);
}
} finally {
workerBusy = false;
currentProcess = null;
}
}
// --- CLI Execution ---
function executeTaskCli(message: string): Promise<any> {
return new Promise((resolve, reject) => {
const args: string[] = [
'-p', message,
'--output-format', 'json',
'--max-turns', TASK_MAX_TURNS(),
];
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_ENTRYPOINT;
delete env.CLAUDE_SPAWNED;
delete env.INNERVOICE_SPAWNED;
console.log(`[TaskManager] Spawning: claude ${args.map((a, i) => i === 1 ? `"${a.substring(0, 40)}..."` : a).join(' ')}`);
const child = spawn('claude', args, {
cwd: TASK_CWD(),
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
currentProcess = child;
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
// No timeout — tasks run until completion
child.on('error', (error) => {
console.error(`[TaskManager] Spawn error: ${error.message}`);
reject(new Error(`Failed to start claude: ${error.message}`));
});
child.on('close', (code) => {
currentProcess = null;
console.log(`[TaskManager] Process exited with code ${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(`[TaskManager] 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'));
}
});
});
}
// --- Helpers ---
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec} сек`;
const min = Math.floor(sec / 60);
const remainSec = sec % 60;
return `${min} мин ${remainSec} сек`;
}
async function notify(chatId: string, text: string): Promise<void> {
if (!sendNotification) return;
try {
await sendNotification(chatId, text);
} catch (error) {
console.error('[TaskManager] Notification failed:', error);
}
}
export function getTaskStatusEmoji(status: Task['status']): string {
switch (status) {
case 'pending': return '⏳';
case 'running': return '🔄';
case 'completed': return '✅';
case 'failed': return '❌';
case 'cancelled': return '🚫';
}
}
export function formatTaskShort(task: Task): string {
const emoji = getTaskStatusEmoji(task.status);
const msg = task.message.length > 40 ? task.message.substring(0, 40) + '...' : task.message;
let line = `${emoji} #${task.id} ${msg}`;
if (task.status === 'completed' && task.durationMs) {
line += ` (${formatDuration(task.durationMs)})`;
}
if (task.status === 'failed' && task.error) {
line += `${task.error.substring(0, 50)}`;
}
return line;
}