This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
function parseClientVersion(text) {
|
||||
const raw = String(text).match(/^ClientVersion=(\d+)\s*$/m)?.[1] || '';
|
||||
if (!raw) return null;
|
||||
const version = Number(raw);
|
||||
return Number.isInteger(version) && version > 0 && version <= 0xffffffff ? version : null;
|
||||
}
|
||||
|
||||
function parseVersionOverride(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return null;
|
||||
if (!/^\d+$/.test(raw)) throw new Error('DOTA_CLIENT_VERSION must contain digits only.');
|
||||
const version = Number(raw);
|
||||
if (!Number.isInteger(version) || version < 1 || version > 0xffffffff) {
|
||||
throw new Error('DOTA_CLIENT_VERSION must be an unsigned 32-bit integer.');
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function defaultSteamInfFiles() {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
process.env.DOTA_STEAM_INF,
|
||||
path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'),
|
||||
path.join(home, '.steam', 'steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'),
|
||||
path.join('/steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveDotaClientVersion(files = defaultSteamInfFiles()) {
|
||||
const override = parseVersionOverride(process.env.DOTA_CLIENT_VERSION);
|
||||
if (override) return { version: override, source: 'environment' };
|
||||
|
||||
for (const file of [...new Set(files)]) {
|
||||
try {
|
||||
const version = parseClientVersion(fs.readFileSync(file, 'utf8'));
|
||||
if (version) return { version, source: file };
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
|
||||
console.warn(`warn: could not read Dota client version ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { version: null, source: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseClientVersion,
|
||||
resolveDotaClientVersion,
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
'use strict';
|
||||
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const MAX_CUSTOM_PLAYERS = 64;
|
||||
|
||||
const DEFAULT_LOBBY_SETTINGS = Object.freeze({
|
||||
lobbyName: '',
|
||||
passKey: '',
|
||||
serverRegion: 3,
|
||||
customGameId: '',
|
||||
customGameMode: '',
|
||||
customMapName: '',
|
||||
customGameCrc: '',
|
||||
customGameTimestamp: '',
|
||||
customMinPlayers: 2,
|
||||
customMaxPlayers: 10,
|
||||
notifyAtPlayers: 8,
|
||||
leaveAtPlayers: 9,
|
||||
adminNames: [],
|
||||
allowSpectating: true,
|
||||
});
|
||||
|
||||
function asInteger(value, name, min, max) {
|
||||
const parsed = typeof value === 'number' ? value : Number(String(value).trim());
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new Error(`${name} must be an integer between ${min} and ${max}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function asOptionalIntegerString(value, name, max) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`);
|
||||
const parsed = BigInt(raw);
|
||||
if (parsed < 0n || parsed > BigInt(max)) throw new Error(`${name} is outside its supported range.`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function asUint64String(value, name, { required = false } = {}) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
if (required) throw new Error(`${name} is required.`);
|
||||
return '';
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`);
|
||||
const parsed = BigInt(raw);
|
||||
if (parsed <= 0n || parsed > 0xffffffffffffffffn) {
|
||||
throw new Error(`${name} must be a non-zero unsigned 64-bit integer.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseAdminNames(value) {
|
||||
const values = Array.isArray(value) ? value : String(value ?? '').split(',');
|
||||
return [...new Set(values.map((entry) => String(entry).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeIdentity(value) {
|
||||
return String(value ?? '').normalize('NFKC').trim().toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
function validateLobbySettings(input = {}) {
|
||||
const merged = { ...DEFAULT_LOBBY_SETTINGS, ...input };
|
||||
const lobbyName = String(merged.lobbyName ?? '').trim();
|
||||
const customGameMode = String(merged.customGameMode ?? '').trim();
|
||||
const customMapName = String(merged.customMapName ?? '').trim();
|
||||
const passKey = String(merged.passKey ?? '');
|
||||
|
||||
if (!lobbyName) throw new Error('Lobby name is required.');
|
||||
if (lobbyName.length > 100) throw new Error('Lobby name must be at most 100 characters.');
|
||||
if (passKey.length > 64) throw new Error('Lobby password must be at most 64 characters.');
|
||||
if (!customGameMode) {
|
||||
throw new Error('Internal addon name is required for a public custom-game lobby. Create the form from a discovered lobby template.');
|
||||
}
|
||||
if (!customMapName) throw new Error('Custom map name is required.');
|
||||
|
||||
const customMinPlayers = asInteger(merged.customMinPlayers, 'Minimum players', 1, MAX_CUSTOM_PLAYERS);
|
||||
const customMaxPlayers = asInteger(merged.customMaxPlayers, 'Maximum players', 2, MAX_CUSTOM_PLAYERS);
|
||||
const notifyAtPlayers = asInteger(merged.notifyAtPlayers, 'Notification threshold', 1, MAX_CUSTOM_PLAYERS);
|
||||
const leaveAtPlayers = asInteger(merged.leaveAtPlayers, 'Auto-leave threshold', 1, MAX_CUSTOM_PLAYERS);
|
||||
|
||||
if (customMinPlayers > customMaxPlayers) {
|
||||
throw new Error('Minimum players cannot exceed maximum players.');
|
||||
}
|
||||
if (notifyAtPlayers > leaveAtPlayers) {
|
||||
throw new Error('Notification threshold must not exceed the auto-leave threshold.');
|
||||
}
|
||||
if (leaveAtPlayers > customMaxPlayers) {
|
||||
throw new Error('Auto-leave threshold cannot exceed the custom-game maximum player count.');
|
||||
}
|
||||
if (leaveAtPlayers === customMaxPlayers) {
|
||||
throw new Error('Auto-leave threshold must be below the custom-game maximum while the bot occupies one lobby slot.');
|
||||
}
|
||||
|
||||
return {
|
||||
lobbyName,
|
||||
passKey,
|
||||
serverRegion: asInteger(merged.serverRegion, 'Server region', 0, UINT32_MAX),
|
||||
customGameId: asUint64String(merged.customGameId, 'Custom game workshop ID', { required: true }),
|
||||
customGameMode,
|
||||
customMapName,
|
||||
customGameCrc: asUint64String(merged.customGameCrc, 'Custom game CRC'),
|
||||
customGameTimestamp: asOptionalIntegerString(merged.customGameTimestamp, 'Custom game timestamp', UINT32_MAX),
|
||||
customMinPlayers,
|
||||
customMaxPlayers,
|
||||
notifyAtPlayers,
|
||||
leaveAtPlayers,
|
||||
adminNames: parseAdminNames(merged.adminNames),
|
||||
allowSpectating: merged.allowSpectating !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function toDotaLobbyDetails(settings) {
|
||||
const details = {
|
||||
gameName: settings.lobbyName,
|
||||
serverRegion: settings.serverRegion,
|
||||
gameMode: 15,
|
||||
allowCheats: false,
|
||||
fillWithBots: false,
|
||||
allowSpectating: settings.allowSpectating,
|
||||
passKey: settings.passKey,
|
||||
lan: false,
|
||||
customMapName: settings.customMapName,
|
||||
customDifficulty: 0,
|
||||
customGameId: settings.customGameId,
|
||||
customMinPlayers: settings.customMinPlayers,
|
||||
customMaxPlayers: settings.customMaxPlayers,
|
||||
visibility: 0,
|
||||
customGamePenalties: true,
|
||||
};
|
||||
|
||||
details.customGameMode = settings.customGameMode;
|
||||
if (settings.customGameCrc) details.customGameCrc = settings.customGameCrc;
|
||||
if (settings.customGameTimestamp) details.customGameTimestamp = Number(settings.customGameTimestamp);
|
||||
return details;
|
||||
}
|
||||
|
||||
function findAdminMember(settings, members) {
|
||||
const wanted = new Set(settings.adminNames.map(normalizeIdentity));
|
||||
if (!wanted.size) return null;
|
||||
return members.find((member) => wanted.has(normalizeIdentity(member.name))
|
||||
|| wanted.has(normalizeIdentity(member.steamId))) || null;
|
||||
}
|
||||
|
||||
function decideLobbySafety(settings, members, lobbyState) {
|
||||
if (lobbyState !== 0) return { action: 'unsafe-state', admin: null };
|
||||
if (members.length >= settings.leaveAtPlayers) return { action: 'player-limit', admin: null };
|
||||
const admin = findAdminMember(settings, members);
|
||||
if (admin) return { action: 'admin-joined', admin };
|
||||
return { action: 'monitor', admin: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_LOBBY_SETTINGS,
|
||||
MAX_CUSTOM_PLAYERS,
|
||||
decideLobbySafety,
|
||||
findAdminMember,
|
||||
normalizeIdentity,
|
||||
parseAdminNames,
|
||||
toDotaLobbyDetails,
|
||||
validateLobbySettings,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
function parseWorkshopPublishData(text) {
|
||||
const sourceFolder = String(text).match(/"source_folder"\s+"([^"]+)"/)?.[1]?.trim() || '';
|
||||
const publishTime = String(text).match(/"publish_time"\s+"(\d+)"/)?.[1] || '';
|
||||
if (!sourceFolder) return null;
|
||||
return { sourceFolder, publishTime };
|
||||
}
|
||||
|
||||
function defaultWorkshopRoots() {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
process.env.DOTA_WORKSHOP_DIR,
|
||||
path.join(home, '.local', 'share', 'Steam', 'steamapps', 'workshop', 'content', '570'),
|
||||
path.join(home, '.steam', 'steam', 'steamapps', 'workshop', 'content', '570'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveLocalWorkshopMetadata(customGameId, roots = defaultWorkshopRoots()) {
|
||||
const id = String(customGameId || '').trim();
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
|
||||
for (const root of [...new Set(roots)]) {
|
||||
const file = path.join(root, id, 'publish_data.txt');
|
||||
try {
|
||||
const metadata = parseWorkshopPublishData(fs.readFileSync(file, 'utf8'));
|
||||
if (metadata) return { ...metadata, file };
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
|
||||
console.warn(`warn: could not read Workshop metadata ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseWorkshopPublishData,
|
||||
resolveLocalWorkshopMetadata,
|
||||
};
|
||||
Reference in New Issue
Block a user