This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user