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

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