Релиз 1.3.1: устаревшее нажатие кнопки (во время перезапуска) больше не обрывает действие и не шлёт ложную ошибку; файлообменник в /help и README

This commit is contained in:
Folist 2026-10-10 16:52:26 +03:00
parent e49a639c69
commit 7aaac2c50b
8 changed files with 53 additions and 6 deletions

View file

@ -2,6 +2,11 @@
Пользовательские описания релизов. Именно ЭТОТ файл (не заголовки коммитов) показывается в уведомлении об обновлении: раздел на версию, пункты — строки, начинающиеся с «- ». Заполняется ПЕРЕД тегированием релиза — scripts/release.sh откажется публиковать версию без её раздела здесь. Коммиты остаются техническими и пользователям не показываются.
## 1.3.1 — 2026-10-10
- Файлообменник: /panel → 📁 Файлы → «📤 Загрузить файл». Положите на свой сервер файл любого размера — сколько вместит диск — и получите ссылку на скачивание, которой можно делиться где угодно. Файл хранится 3 дня от последней ссылки
- Кнопка, нажатая, пока мост перезапускался (например, во время обновления), теперь срабатывает, а не молчит — и в группу больше не приходит ложное «⚠️ Ошибка при обработке сообщения или команды»
## 1.3.0 — 2026-10-10
- Файлы больше 50 МБ из MAX в Telegram пересылаются по ссылке — напрямую больше бот принять не может. Файл сохраняется на вашем сервере, а в тему приходит кнопка «⬇️ Скачать». Пока файл сохраняется, в теме видно, сколько уже скачано

View file

@ -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 файлы на сервере не хранятся.
Как это устроено:

4
package-lock.json generated
View file

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

View file

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

View file

@ -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<typeof answer>) =>
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 не попадают)

View file

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

View file

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

View file

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