diff --git a/.env.example b/.env.example index 6f62f34..b8aa14a 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,8 @@ MAX_SESSION_KEY= # Опционально: версия десктопного MAX, которой представляется мост. Нужна, только если при /login # MAX отвечает «Приложение устарело» (см. README, «Если что-то не работает»). Пусто — встроенная. -# MAX_APP_VERSION=26.35.1 -# MAX_BUILD_NUMBER=80662 +# MAX_APP_VERSION=26.35.2 +# MAX_BUILD_NUMBER=80824 # Optional — the bridge stays MAX-only (no Telegram forwarding) until both are set. TELEGRAM_BOT_TOKEN= diff --git a/README.md b/README.md index e565e54..a1c0084 100644 --- a/README.md +++ b/README.md @@ -254,10 +254,10 @@ cd /opt/telemax && (umask 077; grep -v '^TELEGRAM_PROXY=' .env > .env.tmp && pri 6. **При входе в MAX: «Приложение устарело, пожалуйста, обновитесь».** MAX время от времени перестаёт пускать старые версии клиента, а мост представляется ему десктопным MAX. Сначала проверьте обновление моста: `/version` в группе → «Обновить» — исправление может быть уже выпущено. Если обновления нет — впишите актуальную версию сами, в `.env`: ```bash - MAX_APP_VERSION=26.35.1 - MAX_BUILD_NUMBER=80662 + MAX_APP_VERSION=26.35.2 + MAX_BUILD_NUMBER=80824 ``` - и пересоздайте контейнер: `docker compose up -d --force-recreate`. Текущие номера — в репозитории MAX для Linux: https://download.max.ru/linux/deb/dists/stable/main/binary-amd64/Packages. Первая строка `Version:` — это версия, а число перед `.deb` в строке `Filename:` (например, `MAX-26.35.1.80662.deb`) — номер сборки. + и пересоздайте контейнер: `docker compose up -d --force-recreate`. Текущие номера — в репозитории MAX для Linux: https://download.max.ru/linux/deb/dists/stable/main/binary-amd64/Packages. Первая строка `Version:` — это версия, а число перед `.deb` в строке `Filename:` (например, `MAX-26.35.2.80824.deb`) — номер сборки. 7. **Обновление не прошло на шаге «сборка образа».** Почти всегда на сервере кончилось место: образ с Chromium весит около 1,7 ГБ, и при сборке новый должен поместиться рядом со старым. Мост при этом остаётся на прежней версии и работает. Сначала просто нажмите «Обновить» ещё раз — тяжёлая часть уже собрана и повторно не скачивается. Не помогло — освободите место и повторите: ```bash diff --git a/package-lock.json b/package-lock.json index 80b987d..4a58443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.8-beta.6", + "version": "1.3.8-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.8-beta.6", + "version": "1.3.8-beta.7", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 1425e5e..3d1fc40 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.8-beta.6", + "version": "1.3.8-beta.7", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/attachments.ts b/src/bridge/attachments.ts index 6122197..4cf4ea7 100644 --- a/src/bridge/attachments.ts +++ b/src/bridge/attachments.ts @@ -236,6 +236,63 @@ export function imageExtension(buf: Uint8Array): string { return 'jpg'; } +/** + * An image's pixel size from its header — JPEG (SOF segment), PNG, GIF, BMP, WebP (VP8, VP8L, VP8X) + * — without decoding it; null when unknown or truncated. Pure + exported for unit testing. + */ +export function imageSize(buf: Uint8Array): { width: number; height: number } | null { + const ascii = (from: number, to: number) => String.fromCharCode(...buf.subarray(from, to)); + const u16be = (i: number) => (buf[i]! << 8) | buf[i + 1]!; + const u16le = (i: number) => buf[i]! | (buf[i + 1]! << 8); + const u24le = (i: number) => buf[i]! | (buf[i + 1]! << 8) | (buf[i + 2]! << 16); + const u32be = (i: number) => ((buf[i]! << 24) >>> 0) + (buf[i + 1]! << 16) + (buf[i + 2]! << 8) + buf[i + 3]!; + const ok = (width: number, height: number) => (width > 0 && height > 0 ? { width, height } : null); + if (buf.length >= 24 && buf[0] === 0x89 && ascii(1, 4) === 'PNG') return ok(u32be(16), u32be(20)); + if (buf.length >= 10 && ascii(0, 4) === 'GIF8') return ok(u16le(6), u16le(8)); + if (buf.length >= 26 && ascii(0, 2) === 'BM') { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + return ok(Math.abs(view.getInt32(18, true)), Math.abs(view.getInt32(22, true))); + } + if (buf.length >= 16 && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') { + const chunk = ascii(12, 16); + if (chunk === 'VP8 ' && buf.length >= 30 && buf[23] === 0x9d && buf[24] === 0x01 && buf[25] === 0x2a) return ok(u16le(26) & 0x3fff, u16le(28) & 0x3fff); + if (chunk === 'VP8L' && buf.length >= 25 && buf[20] === 0x2f) { + const [b1, b2, b3, b4] = [buf[21]!, buf[22]!, buf[23]!, buf[24]!]; + return ok(1 + (b1 | ((b2 & 0x3f) << 8)), 1 + (((b2 & 0xc0) >> 6) | (b3 << 2) | ((b4 & 0x0f) << 10))); + } + if (chunk === 'VP8X' && buf.length >= 30) return ok(1 + u24le(24), 1 + u24le(27)); + return null; + } + if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) { + let i = 2; + while (i + 9 < buf.length) { + if (buf[i] !== 0xff) return null; + const marker = buf[i + 1]!; + if (marker === 0xff) { + i += 1; // fill byte + continue; + } + // SOF0–SOF15, except DHT (C4), JPG (C8), DAC (CC): height then width. + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return ok(u16be(i + 7), u16be(i + 5)); + i += 2 + u16be(i + 2); + } + } + return null; +} + +/** + * Whether a picture survives as a Telegram photo. sendPhoto's documented limits are w+h ≤ 10 000 + * and a ratio ≤ 20 — yet a 1810×44 MAX picture (41:1) was ACCEPTED and shrunk to a blurry, + * unzoomable ~1280×31 strip (live 2026-10-13). So: within those limits, and its short side still + * ≥ 64 px once Telegram brings the long side down to 1280. Pure + exported for unit testing. + */ +export function fitsTelegramPhoto({ width, height }: { width: number; height: number }): boolean { + const long = Math.max(width, height); + const short = Math.min(width, height); + if (width + height > 10_000 || long / short > 20) return false; + return long <= 1280 || (short * 1280) / long >= 64; +} + /** Image formats Telegram doesn't take as a photo — they go as a file straight away instead of a doomed sendPhoto. A safety net: MAX serves its photos as its own WebP/JPEG copies (a HEIC came as WebP, live 2026-10-13). */ export const PHOTO_AS_FILE = new Set(['heic', 'tiff', 'avif']); diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index 30a72d6..30bd3fe 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -21,6 +21,8 @@ import { KEYBOARD_CONTACT, pressableButton, PHOTO_AS_FILE, + imageSize, + fitsTelegramPhoto, type MaxAttachment, type DownloadContext, } from './attachments.js'; @@ -707,6 +709,12 @@ async function sendOneAttachment( const ext = downloaded.filename.split('.').pop() ?? 'jpg'; if (ext !== 'jpg') logger.info(`MAX photo arrived as ${ext} (${downloaded.buffer.byteLength} bytes)`); if (PHOTO_AS_FILE.has(ext)) return withFloodRetry(() => bot.telegram.sendDocument(groupId, source, opts)); + // Telegram doesn't refuse a too-narrow picture — it squashes it (1810×44 → a blurry strip): a file keeps it whole. + const size = imageSize(downloaded.buffer); + if (size && !fitsTelegramPhoto(size)) { + logger.info(`MAX photo ${size.width}×${size.height} would be squashed as a Telegram photo — sent as a file`); + return withFloodRetry(() => bot.telegram.sendDocument(groupId, source, opts)); + } try { return await withFloodRetry(() => bot.telegram.sendPhoto(groupId, source, opts)); } catch (err) { diff --git a/src/bridge/telemetry.ts b/src/bridge/telemetry.ts index 58216f1..53b503b 100644 --- a/src/bridge/telemetry.ts +++ b/src/bridge/telemetry.ts @@ -124,6 +124,9 @@ export function detectRestartLoop(previous: number[], now: number): { boots: num } const sentReports: { key: string; at: number }[] = []; +// MAX outages (step 'max-down') keep their own budget: a flaky line must not use up the five +// reports a day that real errors need. +const sentOutageReports: { key: string; at: number }[] = []; /** * Sends an anonymous error report. Best-effort: never throws, no-op when telemetry is off. @@ -136,7 +139,10 @@ export async function reportError(report: ErrorReport): Promise { if (isTelemetryDisabled()) return; try { const error = report.error === undefined ? undefined : scrubError(report.error); - if (!shouldSendReport(sentReports, `${report.kind}|${error ?? ''}`, Date.now())) return; + const budget = report.step === 'max-down' ? sentOutageReports : sentReports; + // An outage is keyed by its cause alone — «~3 min» vs «~4 min» is the same kind of drop. + const key = report.step === 'max-down' ? `max-down|${(error ?? '').replace(/\d+/g, '#')}` : `${report.kind}|${error ?? ''}`; + if (!shouldSendReport(budget, key, Date.now())) return; const freeMb = await statfs(DATA_DIR).then( (s) => Math.round((s.bavail * s.bsize) / 1048576), () => undefined, diff --git a/src/max/client.ts b/src/max/client.ts index 8baa894..4d04322 100644 --- a/src/max/client.ts +++ b/src/max/client.ts @@ -118,10 +118,10 @@ const DEFAULT_PING_INTERVAL_MS = 50_000; // considers outdated («Приложение устарело, пожалуйста, обновитесь» on the SMS request — every new // /login broke on 2026-10-12, the day after desktop 26.35.x shipped). The current pair is the newest // package in MAX's own apt repo: https://download.max.ru/linux/deb/dists/stable/main/binary-amd64/Packages -// («Version: 26.35.1», «Filename: …/MAX-26.35.1.80662.deb» → build 80662). MAX_APP_VERSION and +// («Version: 26.35.2», «Filename: …/MAX-26.35.2.80824.deb» → build 80824). MAX_APP_VERSION and // MAX_BUILD_NUMBER in .env override it, so a user can fix the next such bump without waiting for a release. -const DEFAULT_APP_VERSION = '26.35.1'; -const DEFAULT_BUILD_NUMBER = 80662; +const DEFAULT_APP_VERSION = '26.35.2'; +const DEFAULT_BUILD_NUMBER = 80824; function userAgent(env: NodeJS.ProcessEnv = process.env) { const version = (env.MAX_APP_VERSION ?? '').trim(); const build = Number((env.MAX_BUILD_NUMBER ?? '').trim()); diff --git a/src/server/maxSession.ts b/src/server/maxSession.ts index a35728c..8184d7a 100644 --- a/src/server/maxSession.ts +++ b/src/server/maxSession.ts @@ -40,6 +40,7 @@ export interface SessionClient { connect(): void; disconnect(): void; on(event: 'ready' | 'disconnected', listener: () => void): unknown; + on(event: 'error', listener: (err: unknown) => void): unknown; once(event: 'ready', listener: () => void): unknown; off(event: 'ready', listener: () => void): unknown; login(token: string): Promise<{ sessionToken: string; payload: unknown }>; @@ -57,7 +58,7 @@ export interface MaxSessionDeps { /** Posts the «session rejected» notice (with the login button) to the group. */ postReauthNotice: (text: string) => Promise; /** Anonymous error report (bridge/telemetry.ts) — gets the server's refusal text, nothing else. */ - reportError?: (report: { kind: 'session-rejected'; error: string }) => unknown; + reportError?: (report: { kind: 'session-rejected' | 'internal'; step?: string; error: string }) => unknown; } // The saved session is not wiped on the first resume miss: a fresh socket is reconnected and @@ -107,6 +108,11 @@ export class MaxSessionController { private downReported = false; // Set once the group was told the session was lost, so «восстановлена» only follows an actual loss — once. private sessionLostReported = false; + // The MAX outage behind a «❌ Потеряна связь» notice: when it began and what the client said + // first. Reported anonymously on recovery (noteUp) — while it lasts, the network is down anyway. + private outageStartedAt: number | null = null; + private outageCause: string | null = null; + private lastClientError: { at: number; message: string } | null = null; constructor(private readonly deps: MaxSessionDeps) {} @@ -139,6 +145,10 @@ export class MaxSessionController { if (session) this.lastKnownPhone_ = session.phone; this.deps.client.on('ready', () => this.onReady()); this.deps.client.on('disconnected', () => this.onDisconnected()); + // The client says why a socket died just before 'disconnected' (a missed PING, a reset…). + this.deps.client.on('error', (err: unknown) => { + this.lastClientError = { at: Date.now(), message: err instanceof Error ? err.message : String(err) }; + }); this.connect(); } @@ -155,7 +165,10 @@ export class MaxSessionController { } // Closed on purpose: no reconnect is coming, so «пытаюсь переподключиться» would be false — // and the auth chain died with the socket. Likewise on a new socket. - if (next === 'paused' || next === 'stopped') this.downTimer = clearTimer(this.downTimer); + if (next === 'paused' || next === 'stopped') { + this.downTimer = clearTimer(this.downTimer); + this.outageStartedAt = null; // on purpose — no outage to report + } if (next === 'paused' || next === 'stopped' || next === 'connecting') this.authChainStartedAt = null; } @@ -467,6 +480,11 @@ export class MaxSessionController { /** Starts the outage window unless one runs or an outage is already reported. */ private armDownNotice(): void { + if (this.outageStartedAt == null) { + this.outageStartedAt = Date.now(); + const recent = this.lastClientError && Date.now() - this.lastClientError.at < 10_000 ? this.lastClientError.message : ''; + this.outageCause = outageCause(recent); + } if (this.downTimer != null || this.downReported) return; this.downTimer = setTimeout(() => { this.downTimer = null; @@ -480,13 +498,38 @@ export class MaxSessionController { /** MAX is up again: cancels a running outage window, and reports the recovery of a reported outage. */ private noteUp(): void { this.downTimer = clearTimer(this.downTimer); + const startedAt = this.outageStartedAt; + const cause = this.outageCause; + this.outageStartedAt = null; + this.outageCause = null; if (!this.downReported) return; this.downReported = false; resetErrorKey('max-down'); reportBridgeError('max-up', '✅ Связь с MAX восстановлена.'); + // Counted on the telemetry server (not announced): one install's flaky line vs. MAX down for + // everyone at once shows in how many installs report the same hour (asked for 2026-10-14). + if (startedAt != null) void this.deps.reportError?.({ kind: 'internal', step: 'max-down', error: outageReportText(Date.now() - startedAt, cause) }); } } +/** What ended a MAX socket, as a short code for grouping outage reports (the client's error message in, '' when it gave none). Pure + exported for unit testing. */ +export function outageCause(message: string): string { + const m = message.toLowerCase(); + if (!m) return 'socket-closed'; + if (m.includes('ping')) return 'ping-timeout'; + if (/enotfound|eai_again|getaddrinfo/.test(m)) return 'dns'; + if (/econnrefused|ehostunreach|enetunreach|enetdown/.test(m)) return 'unreachable'; + if (/handshake|tls|certificate|ssl/.test(m)) return 'tls'; + if (/etimedout|timed out|timeout/.test(m)) return 'connect-timeout'; + if (/econnreset|epipe|socket hang up|reset/.test(m)) return 'connection-reset'; + return 'other'; +} + +/** The anonymous outage report: how long MAX was out of reach and how it started — no addresses, no ids. */ +export function outageReportText(durationMs: number, cause: string | null): string { + return `MAX unreachable ~${Math.max(1, Math.round(durationMs / 60_000))} min: ${cause ?? 'socket-closed'}`; +} + function clearTimer(timer: Timer | null): null { if (timer) clearTimeout(timer); return null; diff --git a/telemetry-server/reports.mjs b/telemetry-server/reports.mjs index 1809b45..2ff06d3 100644 --- a/telemetry-server/reports.mjs +++ b/telemetry-server/reports.mjs @@ -53,6 +53,9 @@ export function normalizeError(error) { * big-file service «обрабатывается автоматически» — the bot names the cause and the fix in the topic. */ export function isMutedReport(report) { + // MAX outages (1.3.8+): counted to tell one install's flaky line from MAX down for everyone — + // read in /reports, never announced one by one. + if (report.step === 'max-down') return true; return report.step === 'big-file: service' && /порт/i.test(String(report.error ?? '')); } diff --git a/tests/client-request.test.ts b/tests/client-request.test.ts index 66fb3bb..6a6f1fb 100644 --- a/tests/client-request.test.ts +++ b/tests/client-request.test.ts @@ -286,10 +286,10 @@ describe('isUploadReadyPush', () => { describe('MAX user agent', () => { it('presents the current desktop client and lets .env override it', async () => { const { maxUserAgent } = await import('../src/max/client.js'); - expect(maxUserAgent({})).toMatchObject({ deviceType: 'DESKTOP', appVersion: '26.35.1', buildNumber: 80662 }); + expect(maxUserAgent({})).toMatchObject({ deviceType: 'DESKTOP', appVersion: '26.35.2', buildNumber: 80824 }); expect(maxUserAgent({ MAX_APP_VERSION: '26.40.2', MAX_BUILD_NUMBER: '81234' })).toMatchObject({ appVersion: '26.40.2', buildNumber: 81234 }); // Junk falls back to the built-in pair rather than sending something MAX would refuse outright. - expect(maxUserAgent({ MAX_APP_VERSION: 'latest', MAX_BUILD_NUMBER: '-1' })).toMatchObject({ appVersion: '26.35.1', buildNumber: 80662 }); + expect(maxUserAgent({ MAX_APP_VERSION: 'latest', MAX_BUILD_NUMBER: '-1' })).toMatchObject({ appVersion: '26.35.2', buildNumber: 80824 }); }); }); diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts index a0f715c..7d509e3 100644 --- a/tests/delivery.test.ts +++ b/tests/delivery.test.ts @@ -3,7 +3,7 @@ import type { Telegraf } from 'telegraf'; import { splitTelegramText, truncateCodePoints, truncateUtf16, MAX_TEXT_LIMIT, TELEGRAM_TEXT_LIMIT } from '../src/bridge/text.js'; import { dialogParticipantIds, isFallbackTitle, resolveChatName, resolveContactDisplayName } from '../src/max/names.js'; import { clampTopicTitle, ensureTopicForMaxChat, withTopicLock } from '../src/telegram/bot.js'; -import { imageExtension, telegramSendKind, TELEGRAM_PHOTO_LIMIT_BYTES, TELEGRAM_UPLOAD_LIMIT_BYTES } from '../src/bridge/attachments.js'; +import { fitsTelegramPhoto, imageExtension, imageSize, telegramSendKind, TELEGRAM_PHOTO_LIMIT_BYTES, TELEGRAM_UPLOAD_LIMIT_BYTES } from '../src/bridge/attachments.js'; import { buildLinkIds, describeUnrelayableTelegramMessage, @@ -515,3 +515,36 @@ describe('imageExtension (a MAX picture named by what it really is)', () => { expect(imageExtension(new Uint8Array(0))).toBe('jpg'); }); }); + +describe('imageSize + fitsTelegramPhoto (a narrow MAX picture goes as a file, not a squashed photo)', () => { + const b = (...parts: Array) => Uint8Array.from(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p))); + const be32 = (n: number) => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; + const le16 = (n: number) => [n & 255, (n >>> 8) & 255]; + const le24 = (n: number) => [n & 255, (n >>> 8) & 255, (n >>> 16) & 255]; + const le32 = (n: number) => [n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]; + + it('reads the size from every header MAX hands out', () => { + expect(imageSize(b([0x89], 'PNG', [13, 10, 26, 10], be32(13), 'IHDR', be32(1810), be32(44)))).toEqual({ width: 1810, height: 44 }); + expect(imageSize(b('GIF89a', le16(320), le16(240)))).toEqual({ width: 320, height: 240 }); + expect(imageSize(b('BM', new Array(16).fill(0), le32(640), le32(-480)))).toEqual({ width: 640, height: 480 }); + expect(imageSize(b('RIFF', le32(0), 'WEBP', 'VP8X', le32(10), [0, 0, 0, 0], le24(1809), le24(43)))).toEqual({ width: 1810, height: 44 }); + expect(imageSize(b('RIFF', le32(0), 'WEBP', 'VP8 ', le32(0), [0, 0, 0], [0x9d, 0x01, 0x2a], le16(1200), le16(630)))).toEqual({ width: 1200, height: 630 }); + // VP8L: 14 bits of width-1 then 14 bits of height-1, little-endian bit order: 100×50 → 99, 49. + const bits = 99 | (49 << 14); + expect(imageSize(b('RIFF', le32(0), 'WEBP', 'VP8L', le32(0), [0x2f], le32(bits)))).toEqual({ width: 100, height: 50 }); + const jpeg = b([0xff, 0xd8], [0xff, 0xe0], [0, 16], 'JFIF', new Array(10).fill(0), [0xff, 0xc0], [0, 17, 8], [0, 44], [0x07, 0x12], new Array(12).fill(0)); + expect(imageSize(jpeg)).toEqual({ width: 1810, height: 44 }); + expect(imageSize(b('nothing an image'))).toBeNull(); + expect(imageSize(b([0xff, 0xd8, 0xff]))).toBeNull(); + }); + + it('keeps ordinary photos as photos and sends strips, giants and extreme ratios as files', () => { + expect(fitsTelegramPhoto({ width: 1810, height: 44 })).toBe(false); // the live case + expect(fitsTelegramPhoto({ width: 4000, height: 3000 })).toBe(true); + expect(fitsTelegramPhoto({ width: 1000, height: 60 })).toBe(true); // not downscaled — stays readable + expect(fitsTelegramPhoto({ width: 2000, height: 80 })).toBe(false); // ~1280×51 after Telegram's downscale + expect(fitsTelegramPhoto({ width: 9000, height: 1500 })).toBe(false); // w+h over 10 000 + expect(fitsTelegramPhoto({ width: 300, height: 6100 })).toBe(false); // over 20:1 + expect(fitsTelegramPhoto({ width: 4000, height: 1000 })).toBe(true); // a panorama + }); +}); diff --git a/tests/maxSession.test.ts b/tests/maxSession.test.ts index 6f17e3e..93fdc34 100644 --- a/tests/maxSession.test.ts +++ b/tests/maxSession.test.ts @@ -329,3 +329,39 @@ describe('MaxSessionController — «🚪 Выйти из MAX»', () => { expect(store.clear).toHaveBeenCalledTimes(1); }); }); + +describe('MAX outage reports', () => { + it('name the cause from the client error', async () => { + const { outageCause, outageReportText } = await import('../src/server/maxSession.js'); + expect(outageCause('MAX did not answer PING for 50s — connection is dead, reconnecting')).toBe('ping-timeout'); + expect(outageCause('read ECONNRESET')).toBe('connection-reset'); + expect(outageCause('getaddrinfo EAI_AGAIN api.oneme.ru')).toBe('dns'); + expect(outageCause('connect ECONNREFUSED 1.2.3.4:443')).toBe('unreachable'); + expect(outageCause('TLS handshake timed out after 15000 ms')).toBe('tls'); + expect(outageCause('connect ETIMEDOUT 1.2.3.4:443')).toBe('connect-timeout'); + expect(outageCause('')).toBe('socket-closed'); + expect(outageReportText(185_000, 'ping-timeout')).toBe('MAX unreachable ~3 min: ping-timeout'); + expect(outageReportText(20_000, null)).toBe('MAX unreachable ~1 min: socket-closed'); + }); + + it('a reported outage is sent once MAX is back, with its length and first cause; a short blip is not', async () => { + const { ctl, client, reportError } = setup(null); + client.emit('ready'); + await settle(); + // A blip shorter than the notice window: no group notice, no report. + client.emit('error', new Error('read ECONNRESET')); + client.emit('disconnected'); + await vi.advanceTimersByTimeAsync(30_000); + client.emit('ready'); + await settle(); + expect(reportError).not.toHaveBeenCalled(); + // A real outage: past the 60 s window, then back. + client.emit('error', new Error('MAX did not answer PING for 50s — connection is dead, reconnecting')); + client.emit('disconnected'); + await vi.advanceTimersByTimeAsync(3 * 60_000); + client.emit('ready'); + await settle(); + expect(reportError).toHaveBeenCalledWith({ kind: 'internal', step: 'max-down', error: 'MAX unreachable ~3 min: ping-timeout' }); + expect(ctl.state).toBe('ready'); + }); +}); diff --git a/tests/reports.test.ts b/tests/reports.test.ts index 8b735b8..833da09 100644 --- a/tests/reports.test.ts +++ b/tests/reports.test.ts @@ -146,5 +146,6 @@ describe('muted causes', () => { expect(isMutedReport({ step: 'big-file: service', error: 'у сервера нет белого IP' })).toBe(false); expect(isMutedReport({ step: 'telegram-poll-stall', error: 'getUpdates hung 123s — reconnected' })).toBe(false); expect(isMutedReport({ step: 'update', error: 'порт' })).toBe(false); + expect(isMutedReport({ step: 'max-down', error: 'MAX unreachable ~3 min: ping-timeout' })).toBe(true); }); });