nakama/backend/bots/mainBot.js

519 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const axios = require('axios');
const config = require('../config');
const { log, logError } = require('../middleware/logger');
const User = require('../models/User');
const { normalizeUsername } = require('../services/moderationAdmin');
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
const BOT_TOKEN = config.telegramBotToken;
const TELEGRAM_API = BOT_TOKEN ? `https://api.telegram.org/bot${BOT_TOKEN}` : null;
let isPolling = false;
let offset = 0;
const sendMessage = async (chatId, text, options = {}) => {
if (!TELEGRAM_API) {
log('warn', 'TELEGRAM_BOT_TOKEN не установлен, отправка сообщения невозможна');
return null;
}
try {
const response = await axios.post(`${TELEGRAM_API}/sendMessage`, {
chat_id: chatId,
text,
parse_mode: 'HTML',
...options
});
return response.data;
} catch (error) {
logError('Ошибка отправки сообщения', error, { chatId, text: text.substring(0, 50) });
return null;
}
};
const sendMessageToAllUsers = async (messageText) => {
if (!TELEGRAM_API) {
throw new Error('TELEGRAM_BOT_TOKEN не установлен');
}
try {
const users = await User.find({ banned: { $ne: true } }).select('telegramId');
let sent = 0;
let failed = 0;
for (const user of users) {
try {
await sendMessage(user.telegramId, messageText);
sent++;
// Небольшая задержка, чтобы не превысить лимиты API
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
failed++;
logError('Ошибка отправки сообщения пользователю', error, { telegramId: user.telegramId });
}
}
return { sent, failed, total: users.length };
} catch (error) {
logError('Ошибка массовой отправки сообщений', error);
throw error;
}
};
const requireAdmin = async (message) => {
try {
const telegramId = message.from?.id;
if (!telegramId) return false;
const user = await User.findOne({ telegramId }).select('role');
const username = normalizeUsername(message.from?.username || '');
return user && (user.role === 'admin' || OWNER_USERNAMES.has(username));
} catch (error) {
logError('Ошибка проверки прав админа', error);
return false;
}
};
const getStartMessage = () => {
return `<b>Добро пожаловать в Nakama!</b>
📱 <b>Nakama</b> - социальная сеть для фурри и аниме сообщества.
<b>Основные возможности:</b>
• Создание постов с текстом и изображениями
• Поиск контента через e621 и Gelbooru
• Комментарии и лайки
• Подписки на пользователей
• Система уведомлений
• Фильтры и теги
<b>Как начать:</b>
1. Нажмите кнопку "Войти" ниже, чтобы запустить приложение
2. Создайте свой первый пост
3. Подписывайтесь на интересных пользователей
<b>Поддержка:</b>
Если возникли проблемы, напишите @NakamaReportbot
Приятного использования!`;
};
const handleCommand = async (message) => {
const chatId = message.chat.id;
const text = (message.text || '').trim();
const args = text.split(/\s+/);
const command = args[0].toLowerCase();
if (command === '/start') {
const startParam = message.text.split(' ')[1] || '';
// Если есть start_param (например, post_12345 или ref_ABC123)
// Это обрабатывается при открытии миниаппа, здесь просто показываем инструкцию
const startMessage = getStartMessage();
// Добавить кнопку для открытия миниаппа
let botUsername = 'NakamaSpaceBot';
if (config.telegramBotToken) {
try {
const botInfo = await axios.get(`${TELEGRAM_API}/getMe`);
botUsername = botInfo.data.result.username || 'NakamaSpaceBot';
} catch (error) {
log('warn', 'Не удалось получить имя бота', { error: error.message });
}
}
// Использовать web_app с правильным URL миниаппа
const miniappUrl = 'https://nkm.guru/';
await sendMessage(chatId, startMessage, {
reply_markup: {
inline_keyboard: [[
{
text: '🚀 Открыть Nakama',
web_app: {
url: miniappUrl
}
}
]]
}
});
return;
}
if (command === '/broadcast') {
// Проверка прав
const isAdmin = await requireAdmin(message);
if (!isAdmin) {
await sendMessage(chatId, '❌ Команда доступна только администраторам.');
return;
}
// Получить текст сообщения (все после /broadcast)
const messageText = args.slice(1).join(' ').trim();
if (!messageText) {
await sendMessage(chatId, 'Использование: /broadcast <сообщение>\n\nПример: /broadcast Всем привет!');
return;
}
// Отправить подтверждение
await sendMessage(chatId, '📤 Начинаю рассылку сообщения всем пользователям...');
try {
const result = await sendMessageToAllUsers(messageText);
await sendMessage(chatId,
`✅ Рассылка завершена!\n\n` +
`📊 Статистика:\n` +
`• Отправлено: ${result.sent}\n` +
`• Ошибок: ${result.failed}\n` +
`Всего пользователей: ${result.total}`
);
} catch (error) {
logError('Ошибка рассылки через команду', error);
await sendMessage(chatId, `❌ Ошибка рассылки: ${error.message}`);
}
return;
}
// Игнорируем неизвестные команды
};
const handleInlineQuery = async (inlineQuery) => {
if (!TELEGRAM_API) return;
try {
const query = inlineQuery.query || '';
const queryId = inlineQuery.id;
// Формат: "furry tag1 tag2" или "anime tag1 tag2"
const parts = query.trim().split(/\s+/).filter(p => p.length > 0);
if (parts.length === 0) {
// Если нет запроса, вернуть пустой результат
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: []
});
return;
}
// Первое слово - источник (furry или anime)
const source = parts[0].toLowerCase();
const tags = parts.slice(1);
if (source !== 'furry' && source !== 'anime') {
// Если первый параметр не furry или anime, вернуть пустой результат
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: []
});
return;
}
if (tags.length === 0) {
// Если нет тегов, вернуть пустой результат
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: []
});
return;
}
// Объединить теги в строку для поиска
const tagsQuery = tags.join(' ');
let searchResults = [];
if (source === 'furry') {
// Поиск через e621 API
try {
const config = require('../config');
// Проверить наличие API ключа
if (!config.e621ApiKey) {
log('warn', 'e621 API ключ не настроен для inline query');
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: [],
cache_time: 60
});
return;
}
const E621_USER_AGENT = 'NakamaApp/1.0 (by glpshchn on e621)';
// e621 использует Basic Auth с username:api_key (как в документации)
const username = config.e621Username || 'glpshchn';
const auth = Buffer.from(`${username}:${config.e621ApiKey}`).toString('base64');
const response = await axios.get('https://e621.net/posts.json', {
params: {
tags: tagsQuery,
limit: 50
},
headers: {
'User-Agent': E621_USER_AGENT,
'Authorization': `Basic ${auth}`
},
timeout: 10000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 401/429
});
// Обработка ошибок авторизации
if (response.status === 401) {
log('warn', 'e621 API вернул 401 - неверные учетные данные для inline query');
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: [],
cache_time: 60
});
return;
}
// Обработка rate limit
if (response.status === 429) {
log('warn', 'e621 rate limit (429) для inline query');
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: [],
cache_time: 60
});
return;
}
let postsData = [];
if (Array.isArray(response.data)) {
postsData = response.data;
} else if (response.data && Array.isArray(response.data.posts)) {
postsData = response.data.posts;
} else if (response.data && Array.isArray(response.data.data)) {
postsData = response.data.data;
}
searchResults = postsData
.filter(post => post && post.file && post.file.url)
.slice(0, 50)
.map(post => ({
id: post.id,
url: post.file.url,
preview: post.preview && post.preview.url ? post.preview.url : post.file.url,
tags: post.tags && post.tags.general ? post.tags.general : [],
source: 'e621'
}));
} catch (error) {
// Логировать только если это не 401/429 (они уже обработаны выше)
if (error.response?.status !== 401 && error.response?.status !== 429) {
logError('Ошибка поиска e621 для inline query', error);
}
// Вернуть пустой результат вместо падения
searchResults = [];
}
} else if (source === 'anime') {
// Поиск через Gelbooru API
try {
const config = require('../config');
const response = await axios.get('https://gelbooru.com/index.php', {
params: {
page: 'dapi',
s: 'post',
q: 'index',
json: 1,
tags: tagsQuery,
limit: 50,
api_key: config.gelbooruApiKey,
user_id: config.gelbooruUserId
},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 10000
});
let postsData = [];
if (Array.isArray(response.data)) {
postsData = response.data;
} else if (response.data && response.data.post) {
postsData = Array.isArray(response.data.post) ? response.data.post : [response.data.post];
}
searchResults = postsData
.filter(post => post && post.file_url)
.slice(0, 50)
.map(post => ({
id: post.id,
url: post.file_url,
preview: post.preview_url || post.thumbnail_url || post.file_url,
tags: post.tags ? (typeof post.tags === 'string' ? post.tags.split(' ') : post.tags) : [],
source: 'gelbooru'
}));
} catch (error) {
logError('Ошибка поиска Gelbooru для inline query', error);
}
}
// Получить username бота
let botUsername = 'NakamaSpaceBot';
try {
const botInfo = await axios.get(`${TELEGRAM_API}/getMe`);
botUsername = botInfo.data.result.username || 'NakamaSpaceBot';
} catch (error) {
log('warn', 'Не удалось получить имя бота для inline query', { error: error.message });
}
// Преобразовать результаты в InlineQueryResult
const results = searchResults.map((post, index) => {
const tagsStr = Array.isArray(post.tags) ? post.tags.slice(0, 10).join(' ') : '';
let caption = '';
if (tagsStr) {
caption = `Tags: ${tagsStr}\n\nvia @${botUsername}`;
} else {
caption = `via @${botUsername}`;
}
return {
type: 'photo',
id: `${post.source}_${post.id}_${index}`,
photo_url: post.url,
thumb_url: post.preview || post.url,
caption: caption.substring(0, 1024),
parse_mode: 'HTML'
};
});
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: queryId,
results: results,
cache_time: 300 // 5 минут
});
} catch (error) {
logError('Ошибка обработки inline query', error);
// Отправить пустой результат при ошибке
try {
await axios.post(`${TELEGRAM_API}/answerInlineQuery`, {
inline_query_id: inlineQuery.id,
results: []
});
} catch (e) {
// Игнорировать ошибку отправки пустого результата
}
}
};
const processUpdate = async (update) => {
// Обработка inline query
if (update.inline_query) {
await handleInlineQuery(update.inline_query);
return;
}
const message = update.message || update.edited_message;
if (!message || !message.text) {
return;
}
try {
await handleCommand(message);
} catch (error) {
logError('Ошибка обработки команды основного бота', error, {
chatId: message.chat.id,
text: message.text?.substring(0, 50)
});
}
};
const pollUpdates = async () => {
if (!TELEGRAM_API) {
log('warn', 'Основной бот не запущен: TELEGRAM_BOT_TOKEN не установлен');
return;
}
if (isPolling) {
return;
}
isPolling = true;
log('info', 'Основной бот запущен, опрос обновлений...');
// При первом запуске получить все обновления и установить offset на последний,
// чтобы не отвечать на старые команды
const initializeOffset = async () => {
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
timeout: 1,
allowed_updates: ['message', 'inline_query']
}
});
const updates = response.data.result || [];
if (updates.length > 0) {
// Установить offset на последний update_id + 1, чтобы пропустить все старые обновления
offset = updates[updates.length - 1].update_id + 1;
log('info', `Пропущено ${updates.length} старых обновлений, offset установлен на ${offset}`);
}
} catch (error) {
log('warn', 'Не удалось инициализировать offset, начнем с 0', { error: error.message });
}
};
const poll = async () => {
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
offset,
timeout: 30,
allowed_updates: ['message', 'inline_query']
}
});
const updates = response.data.result || [];
for (const update of updates) {
offset = update.update_id + 1;
await processUpdate(update);
}
// Продолжить опрос
setTimeout(poll, 1000);
} catch (error) {
const errorData = error.response?.data || {};
const errorCode = errorData.error_code;
const errorDescription = errorData.description || error.message;
// Обработка конфликта 409 - другой экземпляр бота уже опрашивает
if (errorCode === 409) {
// Не логируем 409 - это ожидаемая ситуация при конфликте экземпляров
// Подождать дольше перед повторной попыткой
setTimeout(poll, 10000);
} else {
logError('Ошибка опроса Telegram для основного бота', error);
// Переподключиться через 5 секунд
setTimeout(poll, 5000);
}
}
};
// Сначала инициализировать offset, затем начать опрос
initializeOffset().then(() => {
poll();
});
};
const startMainBot = () => {
if (!BOT_TOKEN) {
log('warn', 'Основной бот не запущен: TELEGRAM_BOT_TOKEN не установлен');
return;
}
pollUpdates();
};
module.exports = {
startMainBot,
sendMessageToAllUsers,
sendMessage
};