mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
Телеметрия: раз в 6 часов сверяет версию десктопного MAX из его apt-репозитория с той, которой представляется мост (src/max/client.ts на GitHub), и один раз на каждую новую версию MAX пишет в тему автоотчётов — заранее, до «Приложение устарело»
This commit is contained in:
parent
1eda70b286
commit
d58f8a0b54
6 changed files with 200 additions and 3 deletions
|
|
@ -1,6 +1,6 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY server.mjs reports.mjs site.mjs ./
|
||||
COPY server.mjs reports.mjs site.mjs maxversion.mjs ./
|
||||
ENV PORT=3100
|
||||
EXPOSE 3100
|
||||
CMD ["node", "server.mjs"]
|
||||
|
|
|
|||
|
|
@ -85,3 +85,15 @@ unique visitors are a per-day set of salted hashes kept in memory with a salt th
|
|||
Moscow day, so `data/site.json` holds numbers only. Countries come from the free DB-IP «IP to Country
|
||||
Lite» table (CC BY 4.0 — the `/stats/` page credits it), which the server downloads into
|
||||
`data/dbip-country-lite.csv.gz` on start and refreshes monthly; until it loads, countries read «??».
|
||||
|
||||
## MAX desktop version watch (`maxversion.mjs`)
|
||||
|
||||
MAX refuses logins from a client it considers outdated («Приложение устарело, пожалуйста, обновитесь»),
|
||||
and the bridge presents itself as MAX desktop with `DEFAULT_APP_VERSION` / `DEFAULT_BUILD_NUMBER` from
|
||||
`src/max/client.ts`. Every 6 hours (and a minute after start) the server reads MAX's apt repo
|
||||
(`download.max.ru/linux/deb/…/Packages`: the newest «Version:» and the build in «MAX-<version>.<build>.deb»)
|
||||
and the bridge's numbers from `src/max/client.ts` on GitHub `main`, so it follows every release by itself.
|
||||
When MAX is ahead it posts ONE notice per new MAX version to the maintainer's «🤖 Автоотчёты об ошибках»
|
||||
topic, with the numbers to release (and the `.env` override users can apply meanwhile). State, including
|
||||
the last announced version, is kept in `data/maxversion.json`; if GitHub is unreachable it compares with the
|
||||
bridge numbers it read last time.
|
||||
|
|
|
|||
13
telemetry-server/maxversion.d.mts
Normal file
13
telemetry-server/maxversion.d.mts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Types for maxversion.mjs — only so the vitest suite in tests/ type-checks (tsc --noEmit).
|
||||
export interface ClientVersion {
|
||||
version: string;
|
||||
build: number | null;
|
||||
}
|
||||
export const MAX_PACKAGES_URL: string;
|
||||
export const BRIDGE_CLIENT_URL: string;
|
||||
export function compareVersions(a: string, b: string): -1 | 0 | 1;
|
||||
export function parsePackages(text: string): ClientVersion[];
|
||||
export function newestPackage(list: ClientVersion[]): ClientVersion | null;
|
||||
export function parseBridgeDefaults(source: string): ClientVersion | null;
|
||||
export function shouldNotify(maxLatest: ClientVersion | null, bridge: ClientVersion | null, lastNotified: string | null | undefined): boolean;
|
||||
export function formatVersionNotice(maxLatest: ClientVersion, bridge: ClientVersion): string;
|
||||
67
telemetry-server/maxversion.mjs
Normal file
67
telemetry-server/maxversion.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Early warning for «Приложение устарело»: MAX refuses logins from a client older than it accepts,
|
||||
// and the bridge presents itself as MAX desktop with a version baked into src/max/client.ts. On
|
||||
// 2026-10-12 every new /login broke a day after MAX shipped desktop 26.35.x — this check would have
|
||||
// said so the day before. Twice a day the server compares MAX's newest desktop package with what
|
||||
// the bridge presents and tells the maintainer once per new MAX version.
|
||||
// The decisions live here (pure, unit-tested); server.mjs does the fetching and the posting.
|
||||
|
||||
/** MAX's own apt repo — the same pair the desktop installer carries (version + build in the file name). */
|
||||
export const MAX_PACKAGES_URL = 'https://download.max.ru/linux/deb/dists/stable/main/binary-amd64/Packages';
|
||||
/** What the bridge presents, read from its source on main (follows every release by itself). */
|
||||
export const BRIDGE_CLIENT_URL = 'https://raw.githubusercontent.com/Trollobot/Telemax/main/src/max/client.ts';
|
||||
|
||||
/** «26.35.1» vs «26.9.0» → numeric, part by part. */
|
||||
export function compareVersions(a, b) {
|
||||
const pa = String(a).split('.').map(Number);
|
||||
const pb = String(b).split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d) return d < 0 ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Debian Packages text → [{version, build}] (build from «MAX-<version>.<build>.deb», null if absent). */
|
||||
export function parsePackages(text) {
|
||||
const out = [];
|
||||
for (const block of String(text).split(/\r?\n\r?\n/)) {
|
||||
const version = /^Version:\s*(\d+\.\d+\.\d+)\s*$/m.exec(block)?.[1];
|
||||
if (!version) continue;
|
||||
const build = /^Filename:.*?-\d+\.\d+\.\d+\.(\d+)\.deb\s*$/m.exec(block)?.[1];
|
||||
out.push({ version, build: build ? Number(build) : null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The newest package (by version, then build); null for an empty list. */
|
||||
export function newestPackage(list) {
|
||||
return list.reduce((best, p) => {
|
||||
if (!best) return p;
|
||||
const c = compareVersions(p.version, best.version);
|
||||
return c > 0 || (c === 0 && (p.build ?? 0) > (best.build ?? 0)) ? p : best;
|
||||
}, null);
|
||||
}
|
||||
|
||||
/** The bridge's DEFAULT_APP_VERSION / DEFAULT_BUILD_NUMBER from src/max/client.ts; null when not found. */
|
||||
export function parseBridgeDefaults(source) {
|
||||
const version = /DEFAULT_APP_VERSION\s*=\s*['"`](\d+\.\d+\.\d+)['"`]/.exec(String(source))?.[1];
|
||||
if (!version) return null;
|
||||
const build = /DEFAULT_BUILD_NUMBER\s*=\s*(\d+)/.exec(String(source))?.[1];
|
||||
return { version, build: build ? Number(build) : null };
|
||||
}
|
||||
|
||||
/** Notify when MAX is ahead of the bridge and this MAX version hasn't been announced yet. */
|
||||
export function shouldNotify(maxLatest, bridge, lastNotified) {
|
||||
if (!maxLatest || !bridge) return false;
|
||||
if (compareVersions(maxLatest.version, bridge.version) <= 0) return false;
|
||||
return lastNotified !== maxLatest.version;
|
||||
}
|
||||
|
||||
export function formatVersionNotice(maxLatest, bridge) {
|
||||
const build = maxLatest.build != null ? ` (сборка ${maxLatest.build})` : '';
|
||||
return (
|
||||
`🆕 MAX выпустил десктоп ${maxLatest.version}${build}, а мост представляется ${bridge.version}${bridge.build != null ? ` (${bridge.build})` : ''}.\n\n` +
|
||||
`Если /login начнёт отвечать «Приложение устарело» — выпустить обновление с этими номерами: DEFAULT_APP_VERSION и DEFAULT_BUILD_NUMBER в src/max/client.ts. ` +
|
||||
`Пользователи до релиза могут обойтись .env: MAX_APP_VERSION=${maxLatest.version}${maxLatest.build != null ? `, MAX_BUILD_NUMBER=${maxLatest.build}` : ''}.`
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { createInterface } from 'node:readline';
|
|||
import path from 'node:path';
|
||||
import { sanitizeReport, acceptReport, recordReport, scheduleNotice, dueIn, formatNotice, listReports } from './reports.mjs';
|
||||
import { createSiteStats, summarize, createGeoBuilder, cleanIp } from './site.mjs';
|
||||
import { MAX_PACKAGES_URL, BRIDGE_CLIENT_URL, parsePackages, newestPackage, parseBridgeDefaults, shouldNotify, formatVersionNotice } from './maxversion.mjs';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3100);
|
||||
const DATA_FILE = process.env.DATA_FILE || '/data/installs.json';
|
||||
|
|
@ -35,6 +36,9 @@ const SITE_FILE = process.env.SITE_FILE || path.join(path.dirname(DATA_FILE), 's
|
|||
const GEO_FILE = process.env.GEO_FILE || path.join(path.dirname(DATA_FILE), 'dbip-country-lite.csv.gz');
|
||||
const GEO_MAX_AGE_MS = 35 * 86_400_000; // DB-IP publishes monthly
|
||||
const HIT_LIMIT_PER_IP = 120; // per rolling hour
|
||||
// MAX desktop version watch: what was last seen and announced (maxversion.mjs).
|
||||
const MAXVERSION_FILE = process.env.MAXVERSION_FILE || path.join(path.dirname(DATA_FILE), 'maxversion.json');
|
||||
const MAXVERSION_EVERY_MS = 6 * 3600_000;
|
||||
|
||||
/** installId -> { firstSeen, lastSeen, version } */
|
||||
let db = {};
|
||||
|
|
@ -83,6 +87,11 @@ async function load() {
|
|||
} catch {
|
||||
// no visits yet
|
||||
}
|
||||
try {
|
||||
maxVersionState = { ...maxVersionState, ...JSON.parse(await readFile(MAXVERSION_FILE, 'utf8')) };
|
||||
} catch {
|
||||
// never checked yet
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads this month's (or last month's) DB-IP Lite country table when ours is missing or stale, then loads it. */
|
||||
|
|
@ -183,7 +192,13 @@ async function notifyMaintainer(sig) {
|
|||
}
|
||||
const entries = due.map((s) => reports.signatures[s]).filter(Boolean);
|
||||
if (!entries.length) return;
|
||||
const text = formatNotice(entries);
|
||||
await sendToMaintainer(formatNotice(entries));
|
||||
}
|
||||
|
||||
/** Posts into the maintainer's «🤖 Автоотчёты об ошибках» topic (created once). False when it couldn't. */
|
||||
async function sendToMaintainer(text) {
|
||||
if (!REPORT_BOT_TOKEN || !REPORT_CHAT_ID) return false;
|
||||
let ok = true;
|
||||
try {
|
||||
let thread = REPORT_THREAD_ID || reports.notify.threadId;
|
||||
if (!thread) {
|
||||
|
|
@ -198,9 +213,36 @@ async function notifyMaintainer(sig) {
|
|||
return tg('sendMessage', { text });
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(`report notice failed: ${err.message}`);
|
||||
ok = false;
|
||||
console.log(`maintainer notice failed: ${err.message}`);
|
||||
}
|
||||
void save(REPORTS_FILE, reports);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// --- MAX desktop version watch (maxversion.mjs) -----------------------------------------------
|
||||
/** { bridge: {version, build}, maxLatest, lastNotified, checkedAt } — bridge is the last one read from GitHub. */
|
||||
let maxVersionState = { bridge: null, maxLatest: null, lastNotified: null, checkedAt: 0 };
|
||||
|
||||
async function checkMaxVersion() {
|
||||
try {
|
||||
const res = await fetch(MAX_PACKAGES_URL, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!res.ok) throw new Error(`MAX repo HTTP ${res.status}`);
|
||||
const maxLatest = newestPackage(parsePackages(await res.text()));
|
||||
// GitHub unreachable: keep comparing with what the bridge presented last time.
|
||||
const src = await fetch(BRIDGE_CLIENT_URL, { signal: AbortSignal.timeout(30_000) })
|
||||
.then((r) => (r.ok ? r.text() : null))
|
||||
.catch(() => null);
|
||||
const bridge = (src && parseBridgeDefaults(src)) || maxVersionState.bridge;
|
||||
console.log(`max version: MAX desktop ${maxLatest?.version ?? '?'} (${maxLatest?.build ?? '?'}), bridge presents ${bridge?.version ?? '?'}`);
|
||||
if (shouldNotify(maxLatest, bridge, maxVersionState.lastNotified) && (await sendToMaintainer(formatVersionNotice(maxLatest, bridge)))) {
|
||||
maxVersionState.lastNotified = maxLatest.version;
|
||||
}
|
||||
maxVersionState = { ...maxVersionState, bridge, maxLatest, checkedAt: Date.now() };
|
||||
void save(MAXVERSION_FILE, maxVersionState);
|
||||
} catch (err) {
|
||||
console.log(`max version check failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** /stats and /reports: HTTPS-only + STATS_KEY. Answers 403 itself and returns false when refused. */
|
||||
|
|
@ -350,4 +392,6 @@ setInterval(() => {
|
|||
void refreshGeo();
|
||||
site.prune(Date.now());
|
||||
}, 86_400_000).unref();
|
||||
setTimeout(() => void checkMaxVersion(), 60_000).unref();
|
||||
setInterval(() => void checkMaxVersion(), MAXVERSION_EVERY_MS).unref();
|
||||
server.listen(PORT, () => console.log(`telemetry listening on :${PORT} (data: ${DATA_FILE})`));
|
||||
|
|
|
|||
61
tests/maxversion.test.ts
Normal file
61
tests/maxversion.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { compareVersions, formatVersionNotice, newestPackage, parseBridgeDefaults, parsePackages, shouldNotify } from '../telemetry-server/maxversion.mjs';
|
||||
|
||||
// The telemetry server's early warning for «Приложение устарело» (telemetry-server/maxversion.mjs).
|
||||
|
||||
// The shape of MAX's apt repo Packages file on 2026-10-12 (trimmed).
|
||||
const PACKAGES = [
|
||||
'Package: max',
|
||||
'Version: 26.35.1',
|
||||
'Architecture: amd64',
|
||||
'Filename: pool/main/m/max/MAX-26.35.1.80662.deb',
|
||||
'Size: 311163278',
|
||||
'',
|
||||
'Package: max',
|
||||
'Version: 26.35.0',
|
||||
'Filename: pool/main/m/max/MAX-26.35.0.80520.deb',
|
||||
'',
|
||||
'Package: max',
|
||||
'Version: 26.9.0',
|
||||
'Filename: pool/main/m/max/MAX-26.9.0.70001.deb',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
describe('MAX desktop version watch', () => {
|
||||
it('compares versions numerically', () => {
|
||||
expect(compareVersions('26.35.1', '26.9.0')).toBe(1);
|
||||
expect(compareVersions('26.24.0', '26.35.1')).toBe(-1);
|
||||
expect(compareVersions('26.35.1', '26.35.1')).toBe(0);
|
||||
});
|
||||
|
||||
it('reads the repo and picks the newest package with its build', () => {
|
||||
const list = parsePackages(PACKAGES);
|
||||
expect(list).toHaveLength(3);
|
||||
expect(newestPackage(list)).toEqual({ version: '26.35.1', build: 80662 });
|
||||
expect(newestPackage([])).toBeNull();
|
||||
expect(parsePackages('garbage\n\nVersion: x')).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads what the bridge presents from its source', () => {
|
||||
const src = "const DEFAULT_APP_VERSION = '26.35.1';\nconst DEFAULT_BUILD_NUMBER = 80662;";
|
||||
expect(parseBridgeDefaults(src)).toEqual({ version: '26.35.1', build: 80662 });
|
||||
expect(parseBridgeDefaults('nothing here')).toBeNull();
|
||||
});
|
||||
|
||||
it('notifies once per newer MAX version, never when the bridge is current', () => {
|
||||
const bridge = { version: '26.35.1', build: 80662 };
|
||||
expect(shouldNotify({ version: '26.35.1', build: 80662 }, bridge, null)).toBe(false);
|
||||
expect(shouldNotify({ version: '26.36.0', build: 81000 }, bridge, null)).toBe(true);
|
||||
expect(shouldNotify({ version: '26.36.0', build: 81000 }, bridge, '26.36.0')).toBe(false);
|
||||
expect(shouldNotify({ version: '26.37.0', build: 81500 }, bridge, '26.36.0')).toBe(true);
|
||||
expect(shouldNotify(null, bridge, null)).toBe(false);
|
||||
expect(shouldNotify({ version: '26.36.0', build: 1 }, null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('tells the maintainer the exact numbers to use', () => {
|
||||
const text = formatVersionNotice({ version: '26.36.0', build: 81000 }, { version: '26.35.1', build: 80662 });
|
||||
expect(text).toContain('26.36.0 (сборка 81000)');
|
||||
expect(text).toContain('MAX_APP_VERSION=26.36.0, MAX_BUILD_NUMBER=81000');
|
||||
expect(text).toContain('DEFAULT_APP_VERSION');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue