- Исправлен метод isAdmin на асинхронный с ожиданием результата - Добавлен вывод ошибок в лог при проверке статуса пользователя - В хэндлере команды /summy добавлена проверка прав администратора с удалением сообщения, если пользователь не админ
547 lines
22 KiB
JavaScript
547 lines
22 KiB
JavaScript
const { Telegraf } = require('telegraf');
|
||
const fs = require('fs').promises;
|
||
const path = require('path');
|
||
const { OpenRouterClient } = require('openrouter-kit');
|
||
const { getPrompt } = require('./prompts.js');
|
||
const logger = require('./logger.js');
|
||
const callAI = require("./requestAI");
|
||
const {searchInPrompts, saveInPrompts} = require("./promptResponser");
|
||
const INSTRUCTIONS = require("./promptGen");
|
||
require('dotenv').config();
|
||
const char = {name:'marina'}
|
||
class TelegramHistoryBot {
|
||
constructor(token) {
|
||
this.bot = new Telegraf(token);
|
||
this.historyFile = path.join(__dirname, 'chat_history.json');
|
||
this.history = [];
|
||
|
||
// Инициализация OpenRouter с проверкой ключа
|
||
if (!process.env.OPENROUTER_API_KEY) {
|
||
logger.warn('⚠️ OPENROUTER_API_KEY не найден, ИИ-суммаризация будет недоступна');
|
||
this.openRouter = null;
|
||
} else {
|
||
this.openRouter = new OpenRouterClient({
|
||
apiKey: process.env.OPENROUTER_API_KEY,
|
||
appName: 'telegram-history-bot',
|
||
appVersion: '1.0.0'
|
||
});
|
||
}
|
||
|
||
this.init();
|
||
}
|
||
async isAdmin(ctx, userId = ctx.from.id) {
|
||
try {
|
||
const member = await ctx.getChatMember(userId)
|
||
return ['creator', 'administrator'].includes(member.status)
|
||
} catch (error) {
|
||
logger.error('Ошибка проверки статуса пользователя:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
async init() {
|
||
try {
|
||
await this.loadHistory();
|
||
this.setupHandlers();
|
||
logger.info('✅ Bot запущен и готов к работе');
|
||
} catch (error) {
|
||
logger.error('❌ Ошибка инициализации бота:', error);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
async loadHistory() {
|
||
try {
|
||
const data = await fs.readFile(this.historyFile, 'utf8');
|
||
this.history = JSON.parse(data);
|
||
logger.info(`📚 Загружено ${this.history.length} сообщений из истории`);
|
||
} catch (error) {
|
||
logger.warn('📝 История не найдена, создаем новую');
|
||
this.history = [];
|
||
await this.saveHistory();
|
||
}
|
||
}
|
||
|
||
async saveHistory() {
|
||
try {
|
||
await fs.writeFile(this.historyFile, JSON.stringify(this.history, null, 2));
|
||
} catch (error) {
|
||
logger.error('❌ Ошибка сохранения истории:', error);
|
||
}
|
||
}
|
||
|
||
setupHandlers() {
|
||
this.bot.command('summy', async (ctx) => {
|
||
if (!(await this.isAdmin(ctx))) {
|
||
await ctx.deleteMessage()
|
||
return
|
||
}
|
||
const message = ctx.message.text || '';
|
||
const args = message.replace(/^\/summy(@\w+)?\s*/, ''); // удаляет /summy и возможный @botname
|
||
const trimmed = args.trim(); // удаляет лишние пробелы по краям, если нужно
|
||
logger.info('📊 Получена команда /summy с запросом: ' + trimmed);
|
||
let request = await callAI('', trimmed, 'request');
|
||
|
||
if (typeof request === 'string') {
|
||
try {
|
||
|
||
request = JSON.parse(request);
|
||
} catch {
|
||
request = {persona: "Карл Маркс", messages: 22, hours: 0};
|
||
}
|
||
}
|
||
|
||
let prompt = await searchInPrompts(request["persona"])
|
||
if (!prompt) {
|
||
logger.info('⚠️ Не найден подходящий prompt для запроса, генерируем...')
|
||
prompt=await callAI(INSTRUCTIONS, request["persona"], 'prompt')
|
||
await saveInPrompts(request["persona"], prompt)
|
||
} else {
|
||
logger.info('✅ Найден подходящий prompt для запроса')
|
||
}
|
||
|
||
const options = {persona:request["persona"], promptToUse:prompt}
|
||
if (request["messages"]>0) {
|
||
await this.handleSummaryCommand(ctx, 'last', request["messages"], options);
|
||
} else {
|
||
await this.handleSummaryCommand(ctx, 'hours', request["hours"], options);
|
||
}
|
||
|
||
|
||
});
|
||
// Команды суммаризации - должны быть ДО обработки обычных сообщений
|
||
// this.bot.command('summary_day', async (ctx) => {
|
||
// // if (!this.isAdmin(ctx)) {
|
||
// // await ctx.deleteMessage
|
||
// // return
|
||
// // }
|
||
// logger.info('📊 Получена команда summary_day');
|
||
// const args = ctx.message.text.split(' ');
|
||
// if (args.length > 1) {
|
||
// char.name=args[1]
|
||
// }
|
||
// await ctx.deleteMessage()
|
||
// await this.handleSummaryCommand(ctx, 'day');
|
||
// });
|
||
//
|
||
// this.bot.command('summary_hours', async (ctx) => {
|
||
// logger.info('📊 Получена команда summary_hours');
|
||
// const args = ctx.message.text.split(' ');
|
||
// if (args.length < 2 || isNaN(parseInt(args[1]))) {
|
||
// await ctx.reply('❗ Укажите количество часов: /summary_hours 6');
|
||
// return;
|
||
// }
|
||
// const hours = parseInt(args[1]);
|
||
// await ctx.deleteMessage()
|
||
// await this.handleSummaryCommand(ctx, 'hours', hours);
|
||
// });
|
||
//
|
||
// this.bot.command('summary_last', async (ctx) => {
|
||
// logger.info('📊 Получена команда summary_last');
|
||
// const args = ctx.message.text.split(' ');
|
||
// if (args.length < 2 || isNaN(parseInt(args[1]))) {
|
||
// await ctx.reply('❗ Укажите количество сообщений: /summary_last 50');
|
||
// return;
|
||
// }
|
||
// const count = parseInt(args[1]);
|
||
// await ctx.deleteMessage()
|
||
// await this.handleSummaryCommand(ctx, 'last', count);
|
||
// });
|
||
|
||
// Команды помощи
|
||
//this.bot.command('summary_help', async (ctx) => await this.sendHelp(ctx));
|
||
//this.bot.start(async (ctx) => await this.sendHelp(ctx));
|
||
|
||
// Общий обработчик сообщений (исключая команды)
|
||
this.bot.on('message', async (ctx) => {
|
||
// Пропускаем все команды
|
||
if (ctx.message.text && ctx.message.text.startsWith('/')) {
|
||
return;
|
||
}
|
||
await this.handleMessage(ctx);
|
||
});
|
||
|
||
// Обработка ошибок
|
||
this.bot.catch(async (err, ctx) => {
|
||
logger.error('❌ Ошибка бота:', err);
|
||
try {
|
||
await ctx.reply('❌ Произошла ошибка при обработке команды');
|
||
} catch (replyError) {
|
||
logger.error('❌ Не удалось отправить сообщение об ошибке:', replyError);
|
||
}
|
||
});
|
||
|
||
// Запуск бота
|
||
this.bot.launch();
|
||
logger.info('🚀 Бот запущен');
|
||
|
||
// Graceful stop
|
||
process.once('SIGINT', () => {
|
||
logger.warn('🛑 Получен сигнал SIGINT, останавливаем бота...');
|
||
this.bot.stop('SIGINT');
|
||
});
|
||
process.once('SIGTERM', () => {
|
||
logger.warn('🛑 Получен сигнал SIGTERM, останавливаем бота...');
|
||
this.bot.stop('SIGTERM');
|
||
});
|
||
}
|
||
|
||
async handleMessage(ctx) {
|
||
try {
|
||
const msg = ctx.message;
|
||
|
||
const messageData = {
|
||
id: this.generateUniqueId(),
|
||
telegram_message_id: msg.message_id,
|
||
chat_id: msg.chat.id,
|
||
user_id: msg.from.id,
|
||
username: msg.from.username || null,
|
||
first_name: msg.from.first_name || null,
|
||
last_name: msg.from.last_name || null,
|
||
text: msg.text || null,
|
||
timestamp: new Date().toISOString(),
|
||
date: msg.date * 1000,
|
||
reply_to_message_id: msg.reply_to_message ? msg.reply_to_message.message_id : null,
|
||
reply_to_user_id: msg.reply_to_message ? msg.reply_to_message.from.id : null,
|
||
message_type: this.getMessageType(msg),
|
||
media_info: this.extractMediaInfo(msg)
|
||
};
|
||
|
||
this.history.push(messageData);
|
||
await this.saveHistory();
|
||
|
||
const userName = messageData.first_name || messageData.username || 'Unknown';
|
||
const content = messageData.text || `[${messageData.message_type}]`;
|
||
logger.info(`💾 Сохранено сообщение от ${userName} в ${msg.chat.id} : ${content.substring(0, 50)}${content.length > 50 ? '...' : ''}`);
|
||
} catch (error) {
|
||
logger.error('❌ Ошибка при обработке сообщения:', error);
|
||
}
|
||
}
|
||
|
||
generateUniqueId() {
|
||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||
}
|
||
|
||
getMessageType(msg) {
|
||
if (msg.text) return 'text';
|
||
if (msg.photo) return 'photo';
|
||
if (msg.video) return 'video';
|
||
if (msg.audio) return 'audio';
|
||
if (msg.voice) return 'voice';
|
||
if (msg.document) return 'document';
|
||
if (msg.sticker) return 'sticker';
|
||
if (msg.animation) return 'animation';
|
||
if (msg.video_note) return 'video_note';
|
||
if (msg.location) return 'location';
|
||
if (msg.contact) return 'contact';
|
||
return 'other';
|
||
}
|
||
|
||
extractMediaInfo(msg) {
|
||
const info = {};
|
||
|
||
if ((msg.photo || msg.video) && msg.caption) {
|
||
info.caption = msg.caption;
|
||
}
|
||
if (msg.document) {
|
||
info.file_name = msg.document.file_name;
|
||
if (msg.caption) info.caption = msg.caption;
|
||
}
|
||
if (msg.sticker) {
|
||
info.emoji = msg.sticker.emoji;
|
||
}
|
||
if (msg.location) {
|
||
info.latitude = msg.location.latitude;
|
||
info.longitude = msg.location.longitude;
|
||
}
|
||
if (msg.contact) {
|
||
info.phone_number = msg.contact.phone_number;
|
||
info.first_name = msg.contact.first_name;
|
||
}
|
||
|
||
return Object.keys(info).length > 0 ? info : null;
|
||
}
|
||
|
||
async handleSummaryCommand(ctx, type, value, options = {}) {
|
||
logger.info(`📊 Обработка команды суммаризации: ${type}${value ? ` (${value})` : ''}`);
|
||
|
||
const chatId = ctx.chat.id;
|
||
|
||
let messages = [];
|
||
|
||
try {
|
||
// Показываем индикатор "печатает"
|
||
await ctx.replyWithChatAction('typing');
|
||
|
||
// Получаем сообщения
|
||
switch (type) {
|
||
case 'day':
|
||
messages = this.getMessagesForDay(chatId);
|
||
break;
|
||
case 'hours':
|
||
if (!value || value < 1 || value > 168) {
|
||
await ctx.reply('❗ Количество часов должно быть от 1 до 168');
|
||
return;
|
||
}
|
||
messages = this.getMessagesForHours(chatId, value);
|
||
break;
|
||
case 'last':
|
||
if (!value || value < 1 || value > 1000) {
|
||
await ctx.reply('❗ Количество сообщений должно быть от 1 до 1000');
|
||
return;
|
||
}
|
||
messages = this.getLastMessages(chatId, value);
|
||
break;
|
||
}
|
||
|
||
logger.info(`📈 Найдено ${messages.length} сообщений для суммаризации`);
|
||
|
||
if (messages.length === 0) {
|
||
await ctx.reply('❗ Сообщения для суммаризации не найдены');
|
||
return;
|
||
}
|
||
|
||
if (messages.length < 3) {
|
||
await ctx.reply('❗ Слишком мало сообщений для создания качественной суммаризации (минимум 3)');
|
||
return;
|
||
}
|
||
|
||
// Генерируем суммаризацию
|
||
const summary = await this.generateSummary(messages, type, value, options);
|
||
|
||
// Отправляем результат
|
||
await ctx.reply(summary, { parse_mode: 'HTML' });
|
||
logger.info('✅ Суммаризация отправлена');
|
||
|
||
} catch (error) {
|
||
logger.error('❌ Ошибка при создании суммаризации:', error);
|
||
await ctx.reply('❌ Произошла ошибка при создании суммаризации. Попробуйте позже.');
|
||
}
|
||
}
|
||
|
||
getMessagesForDay(chatId) {
|
||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||
return this.history.filter(msg =>
|
||
msg.chat_id === chatId &&
|
||
new Date(msg.timestamp).getTime() >= oneDayAgo
|
||
);
|
||
}
|
||
|
||
getMessagesForHours(chatId, hours) {
|
||
const timeAgo = Date.now() - (hours * 60 * 60 * 1000);
|
||
return this.history.filter(msg =>
|
||
msg.chat_id === chatId &&
|
||
new Date(msg.timestamp).getTime() >= timeAgo
|
||
);
|
||
}
|
||
|
||
getLastMessages(chatId, count) {
|
||
return this.history
|
||
.filter(msg => msg.chat_id === chatId)
|
||
.slice(-count);
|
||
}
|
||
|
||
async generateSummary(messages, type, value, options = {}) {
|
||
const preparedData = this.prepareDataForAI(messages);
|
||
if (options) {
|
||
|
||
return await this.callAISummarization('Ты получишь данные чата. ' + options.promptToUse, preparedData);
|
||
} else {
|
||
const prompt = this.createSummaryPrompt(type, value);
|
||
return await this.callAISummarization(prompt, preparedData);
|
||
}
|
||
}
|
||
|
||
prepareDataForAI(messages) {
|
||
const participants = this.getUniqueParticipants(messages);
|
||
const mostActiveUser = this.getMostActiveUser(messages);
|
||
const conversationIntensity = this.getConversationIntensity(messages);
|
||
const messageTypesStats = this.getMessageTypesStats(messages);
|
||
|
||
return {
|
||
metadata: {
|
||
total_messages: messages.length,
|
||
time_range: {
|
||
start: messages[0]?.timestamp,
|
||
end: messages[messages.length - 1]?.timestamp
|
||
},
|
||
participants,
|
||
most_active_user: mostActiveUser,
|
||
conversation_intensity: conversationIntensity,
|
||
message_types: messageTypesStats
|
||
},
|
||
raw_messages: messages.map(msg => ({
|
||
id: msg.id,
|
||
user: `${msg.first_name}${msg.last_name ? ' ' + msg.last_name : ''}`,
|
||
username: msg.username,
|
||
display_name: msg.username ? `@${msg.username}` : `${msg.first_name}${msg.last_name ? ' ' + msg.last_name : ''}`,
|
||
text: msg.text,
|
||
timestamp: msg.timestamp,
|
||
message_type: msg.message_type,
|
||
reply_to: msg.reply_to_message_id,
|
||
media_info: msg.media_info,
|
||
is_media: msg.message_type !== 'text'
|
||
}))
|
||
};
|
||
}
|
||
|
||
getUniqueParticipants(messages) {
|
||
const participants = new Map();
|
||
|
||
messages.forEach(msg => {
|
||
if (!participants.has(msg.user_id)) {
|
||
participants.set(msg.user_id, {
|
||
user_id: msg.user_id,
|
||
name: `${msg.first_name}${msg.last_name ? ' ' + msg.last_name : ''}`,
|
||
username: msg.username,
|
||
message_count: 0
|
||
});
|
||
}
|
||
participants.get(msg.user_id).message_count++;
|
||
});
|
||
|
||
return Array.from(participants.values());
|
||
}
|
||
|
||
getMostActiveUser(messages) {
|
||
const participants = this.getUniqueParticipants(messages);
|
||
return participants.reduce((max, participant) =>
|
||
participant.message_count > max.message_count ? participant : max,
|
||
participants[0]
|
||
);
|
||
}
|
||
|
||
getConversationIntensity(messages) {
|
||
if (messages.length < 2) return 'low';
|
||
|
||
const timeSpan = new Date(messages[messages.length - 1].timestamp) - new Date(messages[0].timestamp);
|
||
const hoursSpan = timeSpan / (1000 * 60 * 60);
|
||
|
||
if (hoursSpan === 0) return 'high';
|
||
|
||
const messagesPerHour = messages.length / hoursSpan;
|
||
|
||
if (messagesPerHour > 10) return 'high';
|
||
if (messagesPerHour > 3) return 'medium';
|
||
return 'low';
|
||
}
|
||
|
||
getMessageTypesStats(messages) {
|
||
const stats = {};
|
||
messages.forEach(msg => {
|
||
stats[msg.message_type] = (stats[msg.message_type] || 0) + 1;
|
||
});
|
||
return stats;
|
||
}
|
||
|
||
createSummaryPrompt(type, value) {
|
||
const timeDescription = this.getTimeDescription(type, value);
|
||
logger.info('Выбранный персонаж: ', char)
|
||
return `Ты получишь данные чата Telegram ${timeDescription}. ` +
|
||
getPrompt(char.name)
|
||
|
||
|
||
}
|
||
|
||
getTimeDescription(type, value) {
|
||
switch (type) {
|
||
case 'day': return 'за последние 24 часа';
|
||
case 'hours': return `за последние ${value} часов`;
|
||
case 'last': return `последние ${value} сообщений`;
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
formatMessagesForAI(data) {
|
||
const { metadata, raw_messages } = data;
|
||
|
||
const participantsList = metadata.participants
|
||
.map(p => `${p.name}${p.username ? ` (@${p.username})` : ''} - ${p.message_count} сообщений`)
|
||
.join('\n');
|
||
|
||
const conversationFlow = raw_messages
|
||
.map(msg => {
|
||
const author = msg.display_name;
|
||
const content = msg.is_media
|
||
? `[${msg.message_type}${msg.media_info?.caption ? `: ${msg.media_info.caption}` : ''}]`
|
||
: msg.text;
|
||
const replyMarker = msg.reply_to ? ' (ответ)' : '';
|
||
const timestamp = new Date(msg.timestamp).toLocaleTimeString('ru-RU', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
return `[${timestamp}] ${author}${replyMarker}: ${content}`;
|
||
})
|
||
.join('\n');
|
||
|
||
return `УЧАСТНИКИ ЧАТА:
|
||
${participantsList}
|
||
|
||
ОБЩАЯ ИНФОРМАЦИЯ:
|
||
- Всего сообщений: ${metadata.total_messages}
|
||
- Период: ${new Date(metadata.time_range.start).toLocaleString('ru-RU')} - ${new Date(metadata.time_range.end).toLocaleString('ru-RU')}
|
||
- Самый активный: ${metadata.most_active_user.name} (${metadata.most_active_user.message_count} сообщений)
|
||
- Интенсивность беседы: ${metadata.conversation_intensity}
|
||
|
||
ХОД РАЗГОВОРА:
|
||
${conversationFlow}`;
|
||
}
|
||
async callAISummarization(prompt, data) {
|
||
return await callAI(prompt, this.formatMessagesForAI(data), 'history')
|
||
}
|
||
|
||
generateFallbackSummary(data) {
|
||
const { metadata } = data;
|
||
const mostActive = metadata.most_active_user;
|
||
const participantsCount = metadata.participants.length;
|
||
const messageTypes = Object.keys(metadata.message_types);
|
||
|
||
let summary = `📊 <b>Краткая сводка чата</b>\n\n`;
|
||
summary += `📝 Всего сообщений: ${metadata.total_messages}\n`;
|
||
summary += `👥 Участников: ${participantsCount}\n`;
|
||
summary += `🏆 Самый активный: ${mostActive.name} (${mostActive.message_count} сообщений)\n`;
|
||
summary += `⚡ Интенсивность: ${metadata.conversation_intensity}\n`;
|
||
|
||
if (messageTypes.length > 1) {
|
||
summary += `📱 Типы сообщений: ${messageTypes.join(', ')}\n`;
|
||
}
|
||
|
||
summary += `\n<i>⚠️ Подробная ИИ-суммаризация временно недоступна</i>`;
|
||
|
||
return summary;
|
||
}
|
||
|
||
async sendHelp(ctx) {
|
||
const helpText = `
|
||
🤖 <b>Бот сохраняет всю историю чата</b>
|
||
|
||
📊 <b>Команды суммаризации:</b>
|
||
/summary_day - суммаризация за сутки
|
||
/summary_hours N - за последние N часов
|
||
/summary_last N - последние N сообщений
|
||
|
||
💡 <b>Примеры:</b>
|
||
/summary_hours 6 - за последние 6 часов
|
||
/summary_last 50 - последние 50 сообщений
|
||
|
||
ℹ️ Бот сохраняет все сообщения и создает живые истории на основе переписки.
|
||
|
||
🔧 Поддерживаются все типы сообщений: текст, фото, видео, аудио, документы, стикеры и др.
|
||
`.trim();
|
||
|
||
await ctx.reply(helpText, { parse_mode: 'HTML' });
|
||
}
|
||
}
|
||
|
||
// Запуск бота
|
||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||
|
||
if (!BOT_TOKEN) {
|
||
logger.error('❌ Укажите токен бота в переменной окружения BOT_TOKEN');
|
||
process.exit(1);
|
||
}
|
||
|
||
const bot = new TelegramHistoryBot(BOT_TOKEN);
|
||
|
||
module.exports = TelegramHistoryBot; |