402 lines
14 KiB
JavaScript
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,
|
|
};
|