diff --git a/CHANGELOG.md b/CHANGELOG.md index 6103d78..aea54a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Пользовательские описания релизов. Именно ЭТОТ файл (не заголовки коммитов) показывается в уведомлении об обновлении: раздел на версию, пункты — строки, начинающиеся с «- ». Заполняется ПЕРЕД тегированием релиза — scripts/release.sh откажется публиковать версию без её раздела здесь. Коммиты остаются техническими и пользователям не показываются. +## 1.3.1 — 2026-10-10 + +- Файлообменник: /panel → 📁 Файлы → «📤 Загрузить файл». Положите на свой сервер файл любого размера — сколько вместит диск — и получите ссылку на скачивание, которой можно делиться где угодно. Файл хранится 3 дня от последней ссылки +- Кнопка, нажатая, пока мост перезапускался (например, во время обновления), теперь срабатывает, а не молчит — и в группу больше не приходит ложное «⚠️ Ошибка при обработке сообщения или команды» + ## 1.3.0 — 2026-10-10 - Файлы больше 50 МБ из MAX в Telegram пересылаются по ссылке — напрямую больше бот принять не может. Файл сохраняется на вашем сервере, а в тему приходит кнопка «⬇️ Скачать». Пока файл сохраняется, в теме видно, сколько уже скачано diff --git a/README.md b/README.md index 74ff805..8415984 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ curl -fsSL https://raw.githubusercontent.com/Trollobot/Telemax/main/install.sh | - **Из Telegram в MAX, больше 20 МБ.** Бот отвечает в теме кнопкой «📤 Загрузить файл»: по ссылке открывается страница на вашем сервере, файл загружается из браузера, и мост отправляет его в MAX обычным файлом — до 4 ГБ, это лимит MAX. Чтобы не ждать загрузку дважды (сначала в Telegram, потом по ссылке), напишите в теме чата `/file` — ссылка на загрузку придёт сразу. Большой файл MAX обрабатывает несколько минут — мост дожидается этого сам. - **Из MAX в Telegram, больше 50 МБ.** В теме сразу появляется «📥 Пришёл файл» с прогрессом сохранения, а когда файл ляжет на сервер — кнопка «⬇️ Скачать». -- **Пульт → «📁 Файлы».** Список файлов на сервере: новая ссылка (продлевает хранение), удаление, повторная отправка в MAX, если она не удалась. Кнопка «📤 Загрузить файл» — свой файлообменник: положить файл на сервер и делиться ссылкой где угодно. Файл живёт 3 дня от последней выданной ссылки, потом удаляется сам. Отправленные в MAX файлы на сервере не хранятся. +- **Пульт → «📁 Файлы».** Список файлов на сервере: новая ссылка (продлевает хранение), удаление, повторная отправка в MAX, если она не удалась. Кнопка «📤 Загрузить файл» — свой файлообменник: положить на сервер файл любого размера (сколько вместит диск, лимит MAX тут ни при чём) и делиться ссылкой на скачивание где угодно. Файл живёт 3 дня от последней выданной ссылки, потом удаляется сам. Отправленные в MAX файлы на сервере не хранятся. Как это устроено: diff --git a/package-lock.json b/package-lock.json index 49c3cc8..55e2f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.0", + "version": "1.3.1", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 95591ff..67c79a6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index f813193..f3f75b6 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -36,7 +36,7 @@ import { checkVersion, type VersionStatus } from './version.js'; import { buildStatusText, collectHostStats, maskPhone } from './status.js'; import { toTelegramReaction } from '../max/reactions.js'; import { ChatBannedError, StrikeCounter, SyncCancelledError, liveCursorTime } from './catchUp.js'; -import { isThreadNotFound, isTransientMaxError, isTransientTelegramError, TransientDownloadError, withFloodRetry } from './transient.js'; +import { isStaleCallbackQuery, isThreadNotFound, isTransientMaxError, isTransientTelegramError, TransientDownloadError, withFloodRetry } from './transient.js'; import { createLogger, jsonStringify, redactSecrets } from '../logger.js'; const logger = createLogger('bridge'); @@ -1463,6 +1463,22 @@ export function wireBridge({ // password) — the only login path. In DM so code/password stay private, and admin-gated. const maxAuth = createMaxAuthFlow({ targetGroupId, auth }); + // A button press answered after Telegram's ~15 s window (it waited out a restart) used to throw + // out of every handler that starts with `await ctx.answerCbQuery()` — the action never ran and the + // group got a false «⚠️ Ошибка…». One place for all buttons: such an answer just loses its toast. + bot.use(async (ctx, next) => { + if (ctx.callbackQuery) { + const answer = ctx.answerCbQuery.bind(ctx); + ctx.answerCbQuery = (...args: Parameters) => + answer(...args).catch((err: unknown) => { + if (!isStaleCallbackQuery(err)) throw err; + logger.info('A button press arrived too late to answer (pressed during a restart?) — running it anyway'); + return true; + }); + } + return next(); + }); + // The target group IS the trust boundary; this enforces it. /reboot and /kill only gate on a // confirmation phrase that is public (open-source, echoed in /help), so without this anyone who // finds the bot (a DM, an unrelated group) could trigger them. poll_answer updates carry no @@ -2506,6 +2522,8 @@ export function wireBridge({ /info — карточка контакта или чата (просто в теме) /file — отправить в этот чат MAX большой файл (до 4 ГБ) по ссылке: загружается один раз, мимо лимита Telegram в 20 МБ +Файлообменник: /panel → 📁 Файлы → «📤 Загрузить файл». Положите на свой сервер файл любого размера — сколько вместит диск — и получите ссылку на скачивание, которой можно делиться где угодно. Файл хранится 3 дня от последней ссылки. + Ответом на опрос: /poll — актуальный счёт (голоса из MAX сами в виджет Telegram не попадают) diff --git a/src/bridge/transient.ts b/src/bridge/transient.ts index 26587b8..5e5d0b7 100644 --- a/src/bridge/transient.ts +++ b/src/bridge/transient.ts @@ -144,3 +144,14 @@ export function isThreadNotFound(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return /message thread not found|thread not found|TOPIC_DELETED|TOPIC_ID_INVALID/i.test(msg); } + +/** + * Telegram's answer to answerCbQuery after the press's ~15 s window: «query is too old and response + * timeout expired or query ID is invalid». A press that waited out a restart (an update) arrives + * like that — the action itself can still run, only the toast is lost (seen in a user's anonymous + * report on 1.3.0, 2026-10-10). Not a fault. + */ +export function isStaleCallbackQuery(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /query is too old|query ID is invalid/i.test(msg); +} diff --git a/src/server/app.ts b/src/server/app.ts index 3d52f9d..f69664c 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,7 +9,7 @@ import { SessionStore } from '../store/sessionStore.js'; import { ChatMapStore } from '../store/chatMapStore.js'; import { wireBridge, syncAllChatsToTelegram, type ChatCatchUp } from '../bridge/sync.js'; import { RetryBackoff } from '../bridge/catchUp.js'; -import { isTransientTelegramError } from '../bridge/transient.js'; +import { isStaleCallbackQuery, isTransientTelegramError } from '../bridge/transient.js'; import { configureErrorReporter, reportBridgeError } from '../bridge/errorReporter.js'; import { getAppVersion } from '../bridge/version.js'; import { noteBoot, reportError } from '../bridge/telemetry.js'; @@ -345,6 +345,11 @@ async function startServer(): Promise { logger.warn(`Telegram handler still running past telegraf's 90 s limit (update type: ${ctx.updateType})`); return; } + // A button press answered too late (it waited out a restart) — not a fault: no notice, no report. + if (isStaleCallbackQuery(err)) { + logger.info(`A button press arrived too late to answer (update type: ${ctx.updateType})`); + return; + } logger.error(`Telegram handler failed (update type: ${ctx.updateType}):`, err); reportBridgeError( 'telegram-handler-error', diff --git a/tests/transient.test.ts b/tests/transient.test.ts index 0a1097e..912a5c4 100644 --- a/tests/transient.test.ts +++ b/tests/transient.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + isStaleCallbackQuery, isTransientHttpStatus, isTransientMaxError, isTransientNetworkError, @@ -116,3 +117,10 @@ describe('downloadMaxAttachment transient reporting', () => { await expect(downloadMaxAttachment(file, ctx(denied))).resolves.toBeNull(); }); }); + +describe('isStaleCallbackQuery', () => { + it('recognizes a press answered after the window (a user report on 1.3.0)', () => { + expect(isStaleCallbackQuery(new Error('400: Bad Request: query is too old and response timeout expired or query ID is invalid'))).toBe(true); + expect(isStaleCallbackQuery(new Error('400: Bad Request: message is not modified'))).toBe(false); + }); +});