mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
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:
parent
989058ba67
commit
33cedbfca7
14 changed files with 204 additions and 17 deletions
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue