From d58f8a0b5497c0d7f47b74b3782ba1eb2d0d65a9 Mon Sep 17 00:00:00 2001 From: Folist Date: Mon, 12 Oct 2026 18:52:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D0=BB=D0=B5=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=8F:=20=D1=80=D0=B0=D0=B7=20=D0=B2=206=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D0=BE=D0=B2=20=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D1=82=D0=BE=D0=BF=D0=BD=D0=BE=D0=B3=D0=BE=20MAX=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B5=D0=B3=D0=BE=20apt-=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=81=20?= =?UTF-8?q?=D1=82=D0=BE=D0=B9,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BC=D0=BE=D1=81=D1=82=20(sr?= =?UTF-8?q?c/max/client.ts=20=D0=BD=D0=B0=20GitHub),=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=20=D1=80=D0=B0=D0=B7=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B6=D0=B4=D1=83=D1=8E=20=D0=BD=D0=BE=D0=B2=D1=83=D1=8E?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20MAX=20=D0=BF=D0=B8?= =?UTF-8?q?=D1=88=D0=B5=D1=82=20=D0=B2=20=D1=82=D0=B5=D0=BC=D1=83=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=E2=80=94=20=D0=B7=D0=B0=D1=80=D0=B0=D0=BD=D0=B5=D0=B5,=20?= =?UTF-8?q?=D0=B4=D0=BE=20=C2=AB=D0=9F=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- telemetry-server/Dockerfile | 2 +- telemetry-server/README.md | 12 ++++++ telemetry-server/maxversion.d.mts | 13 ++++++ telemetry-server/maxversion.mjs | 67 +++++++++++++++++++++++++++++++ telemetry-server/server.mjs | 48 +++++++++++++++++++++- tests/maxversion.test.ts | 61 ++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 telemetry-server/maxversion.d.mts create mode 100644 telemetry-server/maxversion.mjs create mode 100644 tests/maxversion.test.ts diff --git a/telemetry-server/Dockerfile b/telemetry-server/Dockerfile index 2fb3cc3..ec3b798 100644 --- a/telemetry-server/Dockerfile +++ b/telemetry-server/Dockerfile @@ -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"] diff --git a/telemetry-server/README.md b/telemetry-server/README.md index d7dd7a9..f44be92 100644 --- a/telemetry-server/README.md +++ b/telemetry-server/README.md @@ -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-..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. diff --git a/telemetry-server/maxversion.d.mts b/telemetry-server/maxversion.d.mts new file mode 100644 index 0000000..b9f4ac5 --- /dev/null +++ b/telemetry-server/maxversion.d.mts @@ -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; diff --git a/telemetry-server/maxversion.mjs b/telemetry-server/maxversion.mjs new file mode 100644 index 0000000..73600ec --- /dev/null +++ b/telemetry-server/maxversion.mjs @@ -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-..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}` : ''}.` + ); +} diff --git a/telemetry-server/server.mjs b/telemetry-server/server.mjs index 812a1ab..6160040 100644 --- a/telemetry-server/server.mjs +++ b/telemetry-server/server.mjs @@ -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})`)); diff --git a/tests/maxversion.test.ts b/tests/maxversion.test.ts new file mode 100644 index 0000000..ad5e911 --- /dev/null +++ b/tests/maxversion.test.ts @@ -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'); + }); +});