Files
doka2-lobby-list/lib/telegram-notifier.js
T
Aleksandr Bogomiakov c239669932
Docker image / build-and-push (push) Successful in 57s
Added lobby auto creation
2026-08-06 20:33:27 +01:00

402 lines
14 KiB
JavaScript

'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,
};