1.3.8-beta.7: обрывы связи с MAX (дольше минуты) уходят анонимным отчётом при восстановлении — длительность и причина (ping-timeout, connection-reset, dns, unreachable, tls, connect-timeout, socket-closed), отдельная квота, на сервере считаются без уведомлений; вытянутые картинки из MAX (размер из заголовка JPEG/PNG/GIF/WebP/BMP: >20:1, w+h>10000 или полоска после ужатия до 1280) приходят файлом; мост представляется MAX 26.35.2 (сборка 80824)

This commit is contained in:
Folist 2026-10-14 12:51:59 +03:00
parent 989058ba67
commit 33cedbfca7
14 changed files with 204 additions and 17 deletions

View file

@ -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=

View file

@ -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

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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']);

View file

@ -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) {

View file

@ -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<void> {
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,

View file

@ -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());

View file

@ -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<unknown>;
/** 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;

View file

@ -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 ?? ''));
}

View file

@ -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 });
});
});

View file

@ -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<string | number[]>) => 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
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});