'use strict'; const { EventEmitter } = require('events'); const MAX_TELEGRAM_ID = 4503599627370495n; const TELEGRAM_TEXT_LIMIT = 4096; const ACTIVE_PHASES = new Set([ 'creating', 'restoring', 'monitoring', 'leaving', 'unsafe-state', 'connection-lost', 'error', ]); const PHASE_LABELS = { creating: 'создание', restoring: 'восстановление после перезапуска', monitoring: 'ожидание игроков', leaving: 'бот выходит', left: 'бот вышел', 'unsafe-state': 'лобби уже запускается', 'connection-lost': 'соединение потеряно', unmanaged: 'автоматизация отключена', error: 'ошибка', idle: 'не активно', }; class TelegramApiError extends Error { constructor(method, description, errorCode = null) { super(`Telegram ${method}: ${description || 'request failed'}`); this.name = 'TelegramApiError'; this.method = method; this.description = description || 'request failed'; this.errorCode = errorCode; } } function validateBotToken(value) { const token = String(value ?? '').trim(); if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) { throw new Error('Telegram bot token has an invalid format. Copy it from @BotFather.'); } return token; } function parseRecipientIds(value) { const rawValues = Array.isArray(value) ? value : String(value ?? '').split(/[\s,;]+/); const result = []; const seen = new Set(); for (const item of rawValues) { const id = String(item ?? '').trim(); if (!id) continue; if (!/^[1-9]\d*$/.test(id) || BigInt(id) > MAX_TELEGRAM_ID) { throw new Error(`Telegram recipient ID "${id}" must be a positive numeric user ID.`); } if (!seen.has(id)) { seen.add(id); result.push(id); } } if (!result.length) throw new Error('At least one Telegram recipient ID is required.'); return result; } function cleanLine(value, maxLength = 80) { const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').trim(); return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; } function formatLobbyMessage(settings = {}, status = {}) { const members = Array.isArray(status.members) ? status.members : []; const humanCount = Number.isInteger(status.humanCount) ? status.humanCount : members.length; const maxPlayers = Number(settings.customMaxPlayers || 0); const phase = PHASE_LABELS[status.phase] || cleanLine(status.phase || 'неизвестно'); const title = cleanLine(settings.lobbyName || 'Dota 2 lobby', 100); const lines = [ `🎮 ${title}`, `Статус: ${phase}`, `Игроки: ${humanCount}/${maxPlayers || '?'}`, ]; if (status.lobbyId) lines.push(`Lobby ID: ${cleanLine(status.lobbyId, 32)}`); if (settings.customMapName) lines.push(`Карта: ${cleanLine(settings.customMapName, 80)}`); if (settings.serverRegion !== undefined) { lines.push(`Регион: ${cleanLine(settings.serverRegionName || settings.serverRegion, 80)}`); } if (status.practicePublished === true) lines.push('Публикация: в списке лобби'); if (status.practicePublished === false) lines.push('Публикация: не найдено в списке'); lines.push('', 'Состав:'); if (!members.length) { lines.push('— пока нет игроков'); } else { members.forEach((member, index) => { const name = cleanLine(member.name, 60); const steamId = cleanLine(member.steamId, 24); lines.push(`${index + 1}. ${name || steamId || 'неизвестный игрок'}${name && steamId ? ` (${steamId})` : ''}`); }); } if (settings.notifyAtPlayers) lines.push('', `Уведомление: ${settings.notifyAtPlayers}`); if (settings.leaveAtPlayers) lines.push(`Выход бота: ${settings.leaveAtPlayers}`); if (status.leaveReason) lines.push(`Причина выхода: ${cleanLine(status.leaveReason, 80)}`); if (status.lastError) lines.push('', `Ошибка: ${cleanLine(status.lastError, 240)}`); const text = lines.join('\n'); return text.length <= TELEGRAM_TEXT_LIMIT ? text : `${text.slice(0, TELEGRAM_TEXT_LIMIT - 2)}…`; } class TelegramNotifier extends EventEmitter { constructor({ store, fetchImpl = globalThis.fetch, debounceMs = 300 }) { super(); if (!store) throw new Error('TelegramNotifier requires a persistent store.'); if (typeof fetchImpl !== 'function') throw new Error('TelegramNotifier requires fetch support.'); this.store = store; this.fetchImpl = fetchImpl; this.debounceMs = debounceMs; const saved = store.load() || {}; this.token = typeof saved.token === 'string' ? saved.token : ''; this.recipientIds = Array.isArray(saved.recipientIds) ? saved.recipientIds.map(String).filter((id) => /^[1-9]\d*$/.test(id)) : []; this.bot = saved.bot && typeof saved.bot === 'object' ? saved.bot : null; this.session = saved.session && typeof saved.session === 'object' ? saved.session : null; this.lastSuccessAt = saved.lastSuccessAt || null; this.lastError = saved.lastError || null; this.pendingSnapshot = null; this.flushTimer = null; this.flushPromise = Promise.resolve(); } publicState() { return { configured: Boolean(this.token && this.recipientIds.length), botUsername: this.bot?.username || null, recipientIds: [...this.recipientIds], recipientCount: this.recipientIds.length, lastSuccessAt: this.lastSuccessAt, lastError: this.lastError, }; } persist() { this.store.save({ token: this.token, recipientIds: this.recipientIds, bot: this.bot, session: this.session, lastSuccessAt: this.lastSuccessAt, lastError: this.lastError, }); } async request(token, method, payload = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); timeout.unref?.(); try { const response = await this.fetchImpl(`https://api.telegram.org/bot${token}/${method}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal, }); const body = await response.json().catch(() => null); if (!response.ok || !body?.ok) { throw new TelegramApiError(method, body?.description || `HTTP ${response.status}`, body?.error_code); } return body.result; } catch (err) { if (err instanceof TelegramApiError) throw err; if (err.name === 'AbortError') throw new TelegramApiError(method, 'request timed out'); throw new TelegramApiError(method, err.message || 'network request failed'); } finally { clearTimeout(timeout); } } async configure({ token, recipientIds }) { const nextToken = String(token ?? '').trim() ? validateBotToken(token) : this.token; if (!nextToken) throw new Error('Telegram bot token is required.'); const nextRecipients = parseRecipientIds(recipientIds); const bot = await this.request(nextToken, 'getMe'); const tokenChanged = nextToken !== this.token; this.token = nextToken; this.recipientIds = nextRecipients; this.bot = { id: String(bot.id || ''), username: bot.username || '' }; this.lastError = null; if (tokenChanged) this.session = null; this.persist(); this.emit('change'); return this.publicState(); } disable() { if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer = null; this.pendingSnapshot = null; this.token = ''; this.recipientIds = []; this.bot = null; this.session = null; this.lastError = null; this.persist(); this.emit('change'); return this.publicState(); } beginLobbySession() { this.session = { key: `pending:${Date.now()}`, lobbyId: null, messages: {}, }; this.persist(); this.emit('change'); } queueLobbyUpdate(settings, status) { this.pendingSnapshot = { settings: { ...settings }, status: { ...status, members: Array.isArray(status?.members) ? status.members.map((member) => ({ ...member })) : [], }, }; if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer = setTimeout(() => { this.flushTimer = null; const snapshot = this.pendingSnapshot; this.pendingSnapshot = null; if (!snapshot) return; this.flushPromise = this.flushPromise .then(() => this.syncLobby(snapshot.settings, snapshot.status)) .catch((err) => this.recordError(err)); }, this.debounceMs); this.flushTimer.unref?.(); } ensureSession(status) { const active = Boolean(status.armed || status.lobbyId || ACTIVE_PHASES.has(status.phase)); if (!this.session && !active) return false; if (!this.session) { this.session = { key: status.lobbyId ? `lobby:${status.lobbyId}` : `pending:${Date.now()}`, lobbyId: status.lobbyId || null, messages: {}, }; } if (status.lobbyId && !this.session.lobbyId) { this.session.lobbyId = String(status.lobbyId); } if (!this.session.messages || typeof this.session.messages !== 'object') { this.session.messages = {}; } return true; } shouldReplaceMessage(err) { return err instanceof TelegramApiError && err.errorCode === 400 && /message to edit not found|message can.t be edited/i.test(err.description); } async syncRecipient(recipientId, text) { const existing = this.session.messages[recipientId]; if (existing?.text === text) return { recipientId, action: 'unchanged' }; if (existing?.messageId) { try { await this.request(this.token, 'editMessageText', { chat_id: recipientId, message_id: existing.messageId, text, disable_web_page_preview: true, }); this.session.messages[recipientId] = { ...existing, text }; return { recipientId, action: 'edited' }; } catch (err) { if (!this.shouldReplaceMessage(err)) throw err; } } const message = await this.request(this.token, 'sendMessage', { chat_id: recipientId, text, disable_web_page_preview: true, }); this.session.messages[recipientId] = { messageId: message.message_id, text, }; return { recipientId, action: 'sent' }; } async syncLobby(settings, status) { if (!this.token || !this.recipientIds.length || !this.ensureSession(status)) { return { delivered: 0, failed: 0 }; } const text = formatLobbyMessage(settings, status); const results = await Promise.allSettled( this.recipientIds.map(async (recipientId) => { try { return await this.syncRecipient(recipientId, text); } catch (err) { throw new Error(`${recipientId}: ${err.message}`); } }), ); const failures = results.filter((result) => result.status === 'rejected'); if (results.some((result) => result.status === 'fulfilled')) { this.lastSuccessAt = new Date().toISOString(); } this.lastError = failures.length ? failures.map((result) => result.reason.message).join('; ') : null; this.persist(); this.emit('change'); return { delivered: results.length - failures.length, failed: failures.length, }; } async sendTest() { if (!this.token || !this.recipientIds.length) { throw new Error('Telegram notifications are not configured.'); } const results = await Promise.allSettled(this.recipientIds.map(async (recipientId) => { try { return await this.request(this.token, 'sendMessage', { chat_id: recipientId, text: '✅ Уведомления Dota 2 lobby подключены.', }); } catch (err) { throw new Error(`${recipientId}: ${err.message}`); } })); const failures = results.filter((result) => result.status === 'rejected'); if (results.length > failures.length) this.lastSuccessAt = new Date().toISOString(); this.lastError = failures.length ? failures.map((result) => result.reason.message).join('; ') : null; this.persist(); this.emit('change'); return { delivered: results.length - failures.length, failed: failures.length, errors: failures.map((result) => result.reason.message), }; } async sendAlert({ title, body }) { if (!this.token || !this.recipientIds.length) return { delivered: 0, failed: 0 }; const text = [`🔔 ${cleanLine(title, 160)}`, cleanLine(body, 500)].filter(Boolean).join('\n'); const results = await Promise.allSettled(this.recipientIds.map(async (recipientId) => { try { return await this.request(this.token, 'sendMessage', { chat_id: recipientId, text }); } catch (err) { throw new Error(`${recipientId}: ${err.message}`); } })); const failures = results.filter((result) => result.status === 'rejected'); if (results.length > failures.length) this.lastSuccessAt = new Date().toISOString(); this.lastError = failures.length ? failures.map((result) => result.reason.message).join('; ') : null; this.persist(); this.emit('change'); return { delivered: results.length - failures.length, failed: failures.length, }; } recordError(err) { this.lastError = err.message || String(err); this.persist(); this.emit('change'); console.error(`Telegram notification failed: ${this.lastError}`); } } module.exports = { TelegramApiError, TelegramNotifier, formatLobbyMessage, parseRecipientIds, validateBotToken, };