From e6705ad0edd088dd56d6328300979d3cedde9c8c Mon Sep 17 00:00:00 2001 From: Folist Date: Tue, 13 Oct 2026 12:49:31 +0300 Subject: [PATCH] =?UTF-8?q?1.3.7-beta.1:=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B6=20Telegram=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D1=82=20=D0=B4=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=202=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D1=83=D1=82=20(=D1=83=20Telegraf=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BC=D0=B0=D1=83=D1=82=20500=20=D1=81)=20=E2=86=92=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=20=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=2010=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82=20?= =?UTF-8?q?=E2=86=92=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5?= =?UTF-8?q?=D1=80=D0=B0,=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20telegram-poll-?= =?UTF-8?q?stall;=20=C2=AB=F0=9F=94=81=20=D0=9F=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D1=82=D0=B8=D1=82=D1=8C=20=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=C2=BB=20=D0=B2=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=E2=86=92=20=D0=A1=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=20(=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B8,=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=81=D0=B0=D0=BC);=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D1=83=20=D0=BC=D0=BE=D1=81=D1=82=D0=B0=20=D0=B2=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BA=D0=B5=20=E2=80=94=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=C2=AB=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D1=8B=20=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=B5=C2=BB=20=D1=81=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B8=20=D0=B1=D0=B0=D0=B3-?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0;=20HISTORY=5FLIMIT=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=B5=20N=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=B0,=20=D1=81=20=D0=BF=D0=BE=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D0=B2=20=D1=82=D0=B5=D0=BC=D0=B5,=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=20=D0=B2=20=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89=D0=B8=D0=BA=D0=B5,=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B2=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8E=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +++ README.md | 6 ++- package-lock.json | 4 +- package.json | 2 +- setup.sh | 31 +++++++++++++ src/bridge/panel.ts | 39 ++++++++++++++-- src/bridge/sync.ts | 84 +++++++++++++++++++++++++++++++--- src/server/app.ts | 94 +++++++++++++++++++++++++++++++++++++++ tests/chatCatchUp.test.ts | 44 +++++++++++++++++- tests/hints.test.ts | 27 +++++++++++ 10 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 tests/hints.test.ts diff --git a/.env.example b/.env.example index 3ed45dd..6f62f34 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ MAX_SESSION_KEY= # оставляйте api2.oneme.ru, иначе не сойдётся TLS-сертификат сервера. # MAX_HOST=155.212.204.150 # MAX_SNI=api2.oneme.ru + +# Сколько последних сообщений каждого чата переносить при первой синхронизации (по умолчанию — всю историю). +# Telegram пускает ~20 сообщений в минуту: чат на 5 000 сообщений иначе заливается около 4 часов. +# HISTORY_LIMIT=300 + # Опционально: версия десктопного MAX, которой представляется мост. Нужна, только если при /login # MAX отвечает «Приложение устарело» (см. README, «Если что-то не работает»). Пусто — встроенная. # MAX_APP_VERSION=26.35.1 diff --git a/README.md b/README.md index 295051b..1c735a7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ - **👤 Найти контакт** — поиск MAX-контакта по номеру, нику или MAX ID; из карточки результата «Начать чат» в один тап создаёт диалог и его тему, а «➕ В контакты MAX» сохраняет человека в контакты вашего MAX (их видно и в приложении на телефоне). Или просто поделитесь в общей теме группы контактом из Telegram — бот найдёт его в MAX по номеру, а в контакты MAX сохранит под тем же именем, что у вас в Telegram (читать вашу книгу контактов Telegram бот не может: Bot API такого доступа не даёт). Человека, который закрыл поиск по номеру в настройках приватности MAX, не найти никак — попросите его написать вам первым, и чат сам появится темой. - **🚫 Управление чатами** — заглушить (бан) или вернуть чат. - **🔐 Вход в MAX** — кнопка ведёт в личку бота, где проходит авторизация (номер + SMS-код, при 2FA — пароль). -- **⚙️ Система** — «📊 Статус» (версия, аптайм, состояние MAX и Telegram, последние сообщения, диск/RAM/нагрузка, доступность обновления), пауза MAX на время (10 мин / 1 час / 1 сутки / до перезапуска моста), обновление, пересинхронизация, список команд. +- **⚙️ Система** — «📊 Статус» (версия, аптайм, состояние MAX и Telegram, последние сообщения, диск/RAM/нагрузка, доступность обновления), пауза MAX на время (10 мин / 1 час / 1 сутки / до перезапуска моста), обновление, пересинхронизация, список команд и «🔁 Перезапустить мост» — без пересборки и без SSH, за 10–20 секунд. Пульт виден всем участникам группы, но кнопки/команды срабатывают только у администраторов. @@ -111,6 +111,10 @@ curl -fsSL https://raw.githubusercontent.com/Trollobot/Telemax/main/install.sh | После успешной авторизации сессия сохраняется зашифрованной в `./data` — повторной авторизации при перезапуске контейнера не требуется. Повторный вход тем же путём (`/login`) нужен только после `/kill`, смены номера или если MAX отклонил сессию (бот сам напишет об этом в группу). +После входа мост переносит историю чатов в темы. Telegram разрешает боту писать в группу примерно 20 сообщений в минуту, поэтому большая история заливается долго: чат на 5 000 сообщений — около 4 часов (новые сообщения при этом приходят сразу). Установщик спрашивает, сколько последних сообщений каждого чата переносить; то же — `HISTORY_LIMIT=300` в `.env` (затем `docker compose up -d --force-recreate`). Тема, куда попала не вся история, начинается с пометки об этом. + +В личке бот работает только для входа в MAX (`/login`); все остальные команды — в группе моста. Если администратор напишет в личку что-то другое, бот подскажет, где пульт. + ## Большие файлы У ботов Telegram жёсткие лимиты: скачать из Telegram бот может файл не больше **20 МБ**, отправить в Telegram — не больше **50 МБ**. MAX принимает файлы до 4 ГБ. Всё, что не пролезает, мост пересылает по ссылке: diff --git a/package-lock.json b/package-lock.json index c76f9a1..dcc03c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.6", + "version": "1.3.7-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.6", + "version": "1.3.7-beta.1", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 321acf1..0b05e97 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.6", + "version": "1.3.7-beta.1", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/setup.sh b/setup.sh index a6c8461..4e9c6b2 100755 --- a/setup.sh +++ b/setup.sh @@ -578,6 +578,13 @@ if [ -f .env ]; then else echo " Большие файлы: включены — по ссылке на этот сервер" fi + local history_limit + history_limit=$(env_read HISTORY_LIMIT) + if [ -n "$history_limit" ]; then + echo " История чатов: при первой синхронизации — последних сообщений: $history_limit" + else + echo " История чатов: вся (ограничить — HISTORY_LIMIT в .env)" + fi echo " MAX-авторизация: через бота — /login в личку (статус виден в /panel)" } @@ -907,6 +914,29 @@ if [ "$FILES_CHECK" != ok ]; then fi fi +# How much of each chat's past the first sync brings in. A bot may post ~20 messages a minute into +# one group, so a 5 000-message work chat took ~4 hours to come in (a user, 2026-10-13). 30-second +# timeout — defaults to the whole history, as before. +echo +echo "История чатов при первом запуске:" +echo " мост переносит её в Telegram со скоростью ~20 сообщений в минуту (так ограничивает Telegram) —" +echo " чат на 5 000 сообщений заливается около 4 часов. Новые сообщения приходят сразу в любом случае." +HISTORY_LIMIT="" +if read -t 30 -rp "Сколько последних сообщений каждого чата перенести? Число (например 300) или Enter (жду 30с) — всю историю: " HISTORY_CHOICE; then + case "${HISTORY_CHOICE:-}" in + '') ;; + *[!0-9]*) echo " (это не число — переношу всю историю)" ;; + *) [ "$HISTORY_CHOICE" -gt 0 ] 2>/dev/null && HISTORY_LIMIT=$HISTORY_CHOICE ;; + esac +else + echo +fi +if [ -n "$HISTORY_LIMIT" ]; then + echo " → история: последних сообщений каждого чата — $HISTORY_LIMIT" +else + echo " → история: вся" +fi + # .env carries the session-encryption key and bot token — don't leave it readable to # other local users. STICKERS is read by docker-compose.yml as a build arg. # Belt and braces: a value that can't be represented must never reach .env, because Compose then @@ -930,6 +960,7 @@ TELEGRAM_PROXY=$(env_quote "$TELEGRAM_PROXY") STICKERS=$(env_quote "$STICKERS") EOF [ "$FILES_SETTING" = off ] && printf "FILES='off'\n" >> .env +[ -n "$HISTORY_LIMIT" ] && printf "HISTORY_LIMIT=%s\n" "$HISTORY_LIMIT" >> .env echo bold "Готово: .env создан." diff --git a/src/bridge/panel.ts b/src/bridge/panel.ts index 9c23e1b..cc44502 100644 --- a/src/bridge/panel.ts +++ b/src/bridge/panel.ts @@ -48,6 +48,8 @@ export interface ControlPanelDeps { offerStore?: (panelMessageId: number) => Promise; /** «📤 Отправить в MAX» for a stored file that didn't make it (bigFiles.ts). */ resendToMax?: (id: string) => Promise<{ ok: boolean; text: string }>; + /** «🔁 Перезапустить мост»: ends the process for Docker to start it again (server/app.ts); `where` = the panel message to put back afterwards. */ + restart?: (where: { chatId: number; messageId: number }) => void; } export interface PauseControl { @@ -342,20 +344,32 @@ function webView(botUsername?: string): View { markup: Markup.inlineKeyboard(rows).reply_markup, }; } -function systemView(paused: boolean): View { +/** «⚙️ Система» — exported for the bridge that comes back from «🔁 Перезапустить мост» (server/app.ts), with `note` under the title. */ +export function systemView(paused: boolean, note?: string): View { const pauseBtn = paused ? Markup.button.callback('▶️ Возобновить MAX', 'tlmx_panel:resume') : Markup.button.callback('⏸ Пауза MAX', 'tlmx_panel:pause'); return { - text: '⚙️ Система:', + text: note ? `⚙️ Система:\n${note}` : '⚙️ Система:', markup: Markup.inlineKeyboard([ [Markup.button.callback('📊 Статус', 'tlmx_panel:status')], [pauseBtn, Markup.button.callback('⬆️ Обновление', 'tlmx_panel:update')], [Markup.button.callback('🔄 Пересинхронизация', 'tlmx_panel:resync'), Markup.button.callback('📋 Команды', 'tlmx_panel:help')], + [Markup.button.callback('🔁 Перезапустить мост', 'tlmx_panel:restart')], [Markup.button.callback('◀️ Назад', 'tlmx_panel:root')], ]).reply_markup, }; } +function restartConfirmView(): View { + return { + text: + '🔁 Перезапустить мост?\nКонтейнер перезапустится без пересборки — примерно за 10–20 секунд. Переписка не пострадает: что придёт за это время, догрузится само. Загрузка большого файла, если она идёт прямо сейчас, прервётся.\nПомогает, если мост ведёт себя странно. Не помог — пришлите /panel → 📊 Статус и логи разработчику.', + markup: Markup.inlineKeyboard([ + [Markup.button.callback('🔁 Да, перезапустить', 'tlmx_panel:restart:go')], + [Markup.button.callback('◀️ Назад', 'tlmx_panel:system')], + ]).reply_markup, + }; +} function pauseView(): View { return { text: '⏸ На сколько поставить MAX на паузу? Приём/отправка остановятся, авто-возобновление по таймеру.\nПауза живёт в памяти: перезапуск моста (в том числе автообновление) снимет её в любом случае.', @@ -368,7 +382,7 @@ function pauseView(): View { } export function wireControlPanel(deps: ControlPanelDeps): void { - const { bot, targetGroupId, max, getActivePhone, triggerFullResync, leaves, startDialog, resolveContactName, getStatus, pause, files, resendToMax, offerStore } = deps; + const { bot, targetGroupId, max, getActivePhone, triggerFullResync, leaves, startDialog, resolveContactName, getStatus, pause, files, resendToMax, offerStore, restart } = deps; const card = (f: StoredFile, link?: string): View => fileCardView(f, files!, link, Boolean(resendToMax)); const root = (): View => rootView(getActivePhone(), pause.until()); const system = (): View => systemView(pause.until() != null); @@ -595,6 +609,25 @@ export function wireControlPanel(deps: ControlPanelDeps): void { void triggerFullResync().catch((err) => logger.error('panel resync failed', err)); }); + // «🔁 Перезапустить мост»: the process ends and Docker starts it again — no rebuild, no SSH. The + // way to unstick a bridge while it still answers (a user asked for one after a host reboot, + // 2026-10-13); a deaf one is the polling watchdog's job (server/app.ts). + bot.action('tlmx_panel:restart', async (ctx) => { + await ctx.answerCbQuery().catch(() => {}); + await edit(ctx, restartConfirmView()); + }); + bot.action('tlmx_panel:restart:go', async (ctx) => { + const chatId = ctx.callbackQuery.message?.chat.id; + const messageId = ctx.callbackQuery.message?.message_id; + if (!restart || chatId == null || messageId == null) { + await ctx.answerCbQuery('Перезапуск отсюда недоступен — docker compose restart в папке моста', { show_alert: true }).catch(() => {}); + return; + } + await ctx.answerCbQuery('Перезапускаю…').catch(() => {}); + await ctx.editMessageText('🔁 Перезапускаю мост… Вернусь через 10–20 секунд — этот пульт обновится сам.').catch(() => {}); + restart({ chatId, messageId }); + }); + // «📊 Статус»: server/bridge health in one card (see status.ts for what is and isn't visible). bot.action('tlmx_panel:status', async (ctx) => { await ctx.answerCbQuery().catch(() => {}); diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index 929ff3e..82bff28 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -774,18 +774,23 @@ export function historyStartTime(now: number = Date.now()): number { * (batches can re-include the boundary message), oldest-first. With `sinceTime` only strictly * newer messages are returned and paging stops once a batch's oldest is at/before it — the cheap * "what's new since last time" every catch-up runs (a push arriving between TCP sessions is - * otherwise lost forever — hit live 2026-08-12, mid-redeploy). + * otherwise lost forever — hit live 2026-08-12, mid-redeploy). `limit` (a chat filled from scratch, + * HISTORY_LIMIT) stops paging once that many are in hand and keeps the newest; `truncated` says + * older messages were left in MAX. */ async function fetchFullHistory( max: MaxClient, chatId: unknown, sinceTime: number | null = null, isCancelled?: () => boolean, -): Promise { + limit = 0, +): Promise<{ messages: MaxHistoryMessage[]; truncated: boolean }> { const all: MaxHistoryMessage[] = []; const seenIds = new Set(); let fromTime = historyStartTime(); for (let i = 0; i < HISTORY_MAX_BATCHES; i++) { + // One more than the limit tells «there is older history» from «the chat is exactly this long». + if (limit > 0 && all.length > limit) break; // A big chat pages for a minute or more — /reboot and /kill wait for the run to stop. if (isCancelled?.()) throw new SyncCancelledError(); const batch = await max.getChatHistory(chatId, fromTime, HISTORY_BATCH_SIZE); @@ -808,7 +813,41 @@ async function fetchFullHistory( fromTime = oldestTime; } all.sort((a, b) => a.time - b.time); - return all; + const truncated = limit > 0 && all.length > limit; + return { messages: truncated ? all.slice(-limit) : all, truncated }; +} + +/** What the bridge's admin reads when writing to the bot's DM (anything but /login). Pure + exported for unit testing. */ +export function adminDmHintText(groupTitle: string): string { + return [ + '👋 Это ваш мост Telemax. Здесь, в личке, работает только вход в MAX — /login.', + '', + `Всё остальное — в группе моста${groupTitle ? ` «${groupTitle}»` : ''}:`, + '• /panel — пульт, в общей теме', + '• /info, /file, /broadcast — в теме нужного чата', + '• /help — все команды', + ].join('\n'); +} + +/** A members' link to the bridge's supergroup: https://t.me/c/. */ +export function bridgeGroupLink(groupId: string): string { + return `https://t.me/c/${groupId.replace(/^-100/, '')}`; +} + +/** The first line of a topic HISTORY_LIMIT cut short: «последние 300 сообщений» (agreed with the number). */ +export function historyLimitNotice(limit: number): string { + const d = limit % 10; + const dd = limit % 100; + const one = d === 1 && dd !== 11; + const few = d >= 2 && d <= 4 && (dd < 12 || dd > 14); + const phrase = one ? `последнее ${limit} сообщение` : `последние ${limit} ${few ? 'сообщения' : 'сообщений'}`; + return `📜 Здесь ${phrase} этого чата — так задано при установке (HISTORY_LIMIT в .env). Более ранняя переписка осталась в MAX.`; +} + +/** HISTORY_LIMIT from .env: how many of a chat's newest messages a topic filled from scratch gets (0 = all). */ +export function historyLimitFromEnv(env: NodeJS.ProcessEnv = process.env): number { + const n = Number((env.HISTORY_LIMIT ?? '').trim()); + return Number.isInteger(n) && n > 0 ? n : 0; } /** A received forward's `link` — the same shape on PUSH_MESSAGE and in CHAT_HISTORY. */ @@ -952,6 +991,8 @@ export interface ChatCatchUpDeps { paceMs?: number; /** A message the catch-up put into its topic — «трансляция» copies it on (broadcast.ts; its cursor drops replays). */ onRelayed?: (c: IncomingCopy) => void; + /** HISTORY_LIMIT: newest messages a topic filled from scratch gets (0 / unset = the whole history). */ + historyLimit?: number; } /** @@ -1093,9 +1134,17 @@ export class ChatCatchUp { */ private async replay(chatId: unknown, topicId: number, cursor: number | null, cancelled: () => boolean): Promise { const session = this.session; - const history = await fetchFullHistory(this.deps.max, chatId, cursor, cancelled); + // HISTORY_LIMIT caps only a topic filled from scratch; a catch-up from a cursor takes everything new. + const limit = cursor == null ? (this.deps.historyLimit ?? 0) : 0; + const { messages: history, truncated } = await fetchFullHistory(this.deps.max, chatId, cursor, cancelled, limit); if (history.length > 0) { - logger.info(`${cursor == null ? 'Backfilling' : 'Catching up on'} ${history.length} messages for MAX chat ${String(chatId)}`); + logger.info( + `${cursor == null ? 'Backfilling' : 'Catching up on'} ${history.length} messages for MAX chat ${String(chatId)}${truncated ? ` (the newest ${limit} — HISTORY_LIMIT)` : ''}`, + ); + // Says why the topic starts mid-conversation — a 5 000-message chat needs ~4 hours in full. + if (truncated) { + await sendTextPieces(this.deps.bot, this.deps.groupId, topicId, historyLimitNotice(limit)); + } await this.backfill(chatId, topicId, history, cancelled); } if (session === this.session) this.caughtUp.add(String(chatId)); @@ -1473,6 +1522,8 @@ export interface BridgeOptions { auth: MaxAuthCallbacks; /** The panel's MAX pause (server/maxSession.ts). */ pause: PauseControl; + /** The panel's «🔁 Перезапустить мост»: ends the process for Docker to start again (server/app.ts). */ + restartBridge?: (where: { chatId: number; messageId: number }) => void; } /** Wires MAX push messages <-> Telegram forum topics in both directions (ТЗ.md §1.2). */ @@ -1496,6 +1547,7 @@ export function wireBridge({ suspendChatSync, auth, pause, + restartBridge, }: BridgeOptions): WiredBridge { // Bug-report channel: private DMs from outsiders become bug reports. The inbox is only ON where // BUGREPORT_INBOX is set (the maintainer's prod bot); elsewhere outsiders get a redirect stub. @@ -1554,6 +1606,7 @@ export function wireBridge({ // A broadcast's chat picker, then the /login auth flow get first refusal (both admin-gate internally). if (await broadcasts.handlePrivate(ctx)) return; if (await maxAuth.handlePrivate(ctx)) return; + if (await adminDmHint(ctx)) return; await bugReports.handleIncomingPrivate(ctx); return; } @@ -1611,6 +1664,25 @@ export function wireBridge({ } }); + // The bridge's own admin writing to the bot's DM is told where the bridge lives instead of getting + // the outsiders' bug-report redirect: a user typed /panel, /info, /broadcast here and got «пишите в + // баг-бот» every time, and took the bridge for broken (2026-10-13). Only /login works in the DM. + let bridgeGroupTitle: string | undefined; + async function adminDmHint(ctx: Context): Promise { + const userId = ctx.from?.id; + if (userId == null || !ctx.message || !(await isGroupAdmin(userId).catch(() => false))) return false; + if (bridgeGroupTitle === undefined) { + const chat = await bot.telegram.getChat(targetGroupId).catch(() => undefined); + bridgeGroupTitle = chat && 'title' in chat ? chat.title : ''; + } + await ctx + .reply(adminDmHintText(bridgeGroupTitle), { + reply_markup: Markup.inlineKeyboard([[Markup.button.url('➡️ Открыть группу моста', bridgeGroupLink(targetGroupId))]]).reply_markup, + }) + .catch((err) => logger.error('Failed to send the admin DM hint', err)); + return true; + } + /** Creator/administrator of the bridge group — the one notion of "admin" for commands, buttons and the 👎 delete gesture. Throws when Telegram can't be asked; callers fail closed. */ async function isGroupAdmin(userId: number): Promise { const member = await bot.telegram.getChatMember(targetGroupId, userId); @@ -1640,6 +1712,7 @@ export function wireBridge({ forgetChatLinks, isWiping: () => wiping, onRelayed: (c) => broadcasts.copyIncoming(c), + historyLimit: historyLimitFromEnv(), }); /** @@ -2925,6 +2998,7 @@ export function wireBridge({ triggerFullResync, startDialog, pause, + restart: restartBridge, files: fileShare, resendToMax: (id) => bigFiles.resendToMax(id), offerStore: (panelMessageId) => bigFiles.offerStore(panelMessageId), diff --git a/src/server/app.ts b/src/server/app.ts index f6075c1..17e5994 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -8,6 +8,7 @@ import { dialogParticipantIds, extractMyAccountId, resolveChatName, type Contact import { SessionStore } from '../store/sessionStore.js'; import { ChatMapStore } from '../store/chatMapStore.js'; import { wireBridge, syncAllChatsToTelegram, type ChatCatchUp } from '../bridge/sync.js'; +import { systemView } from '../bridge/panel.js'; import { RetryBackoff } from '../bridge/catchUp.js'; import { isStaleCallbackQuery, isTransientTelegramError } from '../bridge/transient.js'; import { configureErrorReporter, reportBridgeError } from '../bridge/errorReporter.js'; @@ -381,8 +382,10 @@ async function startServer(): Promise { stop: () => maxSession.unpause(), until: () => maxSession.pausedUntil, }, + restartBridge: (where) => void restartFromPanel(where), })); chatSync.setRetryHandler(scheduleCatchUpRetry); + watchPolling(bot); launchTelegramBotWithRetry(bot); // Route throttled operator error notices (MAX down, delivery failures, internal // errors) to the target group. Captured in a const so the closure keeps the @@ -545,6 +548,96 @@ async function announceGroupReadyOnce(bot: Telegraf): Promise { // delay starts over instead of staying at the 60 s cap accumulated over the process's life. const TELEGRAM_STABLE_POLLING_MS = 10 * 60_000; +// --- Telegram polling watchdog ------------------------------------------------------------------- +// Telegraf gives every API call 500 s (hard-coded), and getUpdates is a ≤50 s long poll. When its +// connection dies quietly — a local proxy that came up after the bridge, a NAT dropping it — the +// bridge looked connected but was deaf for up to 8 minutes (a user's home server after a reboot, +// 2026-10-13). A poll in flight past POLL_STALL_MS is cut and polling relaunched; a second stall +// soon after that ends the process and Docker (restart: unless-stopped) starts it afresh. +const POLL_STALL_MS = 120_000; +const POLL_RESTALL_WINDOW_MS = 10 * 60_000; +let pollInFlightSince: number | null = null; +let lastPollRelaunchAt = 0; + +/** + * Notes when a polling getUpdates goes out and when it settles — the watchdog's only signal. Only the + * long polls (`timeout` > 0): a stopped Telegraf also sends a short `{offset, limit: 1}` one with no + * abort signal, which on a dead connection would hang and look like a second stall. + */ +function watchPolling(bot_: Telegraf): void { + const callApi = bot_.telegram.callApi.bind(bot_.telegram); + bot_.telegram.callApi = ((method: string, payload: unknown, extra?: unknown) => { + const longPoll = method === 'getUpdates' && Number((payload as { timeout?: unknown } | null)?.timeout) > 0; + if (!longPoll) return (callApi as (m: string, p: unknown, e?: unknown) => Promise)(method, payload, extra); + pollInFlightSince = Date.now(); + return (callApi as (m: string, p: unknown, e?: unknown) => Promise)(method, payload, extra).finally(() => { + pollInFlightSince = null; + }); + }) as typeof bot_.telegram.callApi; + setInterval(() => { + if (pollInFlightSince == null || Date.now() - pollInFlightSince < POLL_STALL_MS) return; + const stalledFor = Math.round((Date.now() - pollInFlightSince) / 1000); + pollInFlightSince = null; + if (Date.now() - lastPollRelaunchAt < POLL_RESTALL_WINDOW_MS) { + logger.error(`Telegram polling stalled again (${stalledFor}s without an answer) soon after a reconnect — restarting the bridge`); + void reportError({ kind: 'internal', step: 'telegram-poll-stall', error: new Error(`getUpdates hung ${stalledFor}s twice — restart`) }); + exitForRestart(1); + return; + } + lastPollRelaunchAt = Date.now(); + logger.error(`Telegram polling stalled: getUpdates unanswered for ${stalledFor}s — cutting it and reconnecting`); + void reportError({ kind: 'internal', step: 'telegram-poll-stall', error: new Error(`getUpdates hung ${stalledFor}s — reconnected`) }); + try { + bot_.stop('watchdog'); // aborts the hung request; that launch() then resolves quietly + } catch { + // not running — the relaunch below starts it + } + // After the stopped loop's own short getUpdates (offset sync): two at once get a 409 from Telegram. + setTimeout(() => void launchTelegramBotWithRetry(bot_), 5_000); + }, 30_000).unref(); +} + +/** Ends the process for Docker (restart: unless-stopped) to start it again — the panel's «Перезапустить» and the watchdog's last resort. */ +function exitForRestart(code: number): void { + try { + maxSession.shutdown(); + } catch { + // exiting anyway + } + try { + bot?.stop('restart'); + } catch { + // not running + } + setTimeout(() => process.exit(code), 800); +} + +// «🔁 Перезапустить мост» (panel → Система): where the press came from, so the bridge that comes +// back can put the panel back with «✅ Мост перезапущен» instead of leaving «Перезапускаю…». +const RESTART_REQUESTED_MARKER = path.join(process.cwd(), '.data', 'restart-requested'); + +async function restartFromPanel(where: { chatId: number; messageId: number }): Promise { + logger.info('Restart requested from the panel'); + await writeFile(RESTART_REQUESTED_MARKER, JSON.stringify({ ...where, at: Date.now() })).catch((err) => logger.error('Failed to write the restart marker', err)); + exitForRestart(0); +} + +async function reportIfJustRestarted(bot_: Telegraf): Promise { + let marker: { chatId?: number; messageId?: number; at?: number }; + try { + marker = JSON.parse(await readFile(RESTART_REQUESTED_MARKER, 'utf8')) as typeof marker; + } catch { + return; + } + await unlink(RESTART_REQUESTED_MARKER).catch(() => {}); + // A marker older than a few minutes is left over from a restart that never came back that way. + if (marker.chatId == null || marker.messageId == null || Date.now() - (marker.at ?? 0) > 10 * 60_000) return; + const { text, markup } = systemView(maxSession.pausedUntil != null, '✅ Мост перезапущен — всё работает.'); + await bot_.telegram + .editMessageText(marker.chatId, marker.messageId, undefined, text, { reply_markup: markup }) + .catch((err) => logger.error('Failed to put the panel back after a restart', err)); +} + function retryTelegramLaunch(bot: Telegraf, attempt: number, reason: unknown): void { tgActive = false; const idx = Math.min(attempt, TELEGRAM_RETRY_DELAYS_MS.length - 1); @@ -578,6 +671,7 @@ async function launchTelegramBotWithRetry(bot: Telegraf, attempt = 0): Promise logger.error('Failed to set bot description', err)); bot.telegram.setMyCommands(BOT_COMMANDS).catch((err) => logger.error('Failed to set bot commands', err)); void reportIfJustUpdated(bot); + void reportIfJustRestarted(bot); // SessionStore parked an unreadable session file (corrupt, or encrypted with a different // MAX_SESSION_KEY — the usual cause is restoring ./data without carrying .env across). The bridge // is up but unauthenticated, so say so in the group with the same re-auth prompt a rejected diff --git a/tests/chatCatchUp.test.ts b/tests/chatCatchUp.test.ts index e2cc250..c9eaca7 100644 --- a/tests/chatCatchUp.test.ts +++ b/tests/chatCatchUp.test.ts @@ -6,7 +6,7 @@ import type { Telegraf } from 'telegraf'; import type { MaxClient, MaxHistoryMessage } from '../src/max/client.js'; import { ChatMapStore } from '../src/store/chatMapStore.js'; import { ChatBannedError, StrikeCounter } from '../src/bridge/catchUp.js'; -import { ChatCatchUp, MessageLinkStore, syncAllChatsToTelegram } from '../src/bridge/sync.js'; +import { ChatCatchUp, MessageLinkStore, historyLimitFromEnv, historyLimitNotice, syncAllChatsToTelegram } from '../src/bridge/sync.js'; // One writer per chat, catch-up on first touch, and the cursor gate — see ChatCatchUp in sync.ts. @@ -27,7 +27,7 @@ const threadNotFound = () => Object.assign(new Error('400: Bad Request: message const msg = (id: number, time: number, text: string): MaxHistoryMessage => ({ id, time, text }) as unknown as MaxHistoryMessage; const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)); -function fakeWorld(history: MaxHistoryMessage[] = [], onSend?: (text: string, topicId: number) => Promise | void, strikes?: StrikeCounter) { +function fakeWorld(history: MaxHistoryMessage[] = [], onSend?: (text: string, topicId: number) => Promise | void, strikes?: StrikeCounter, historyLimit?: number) { const sends: Array<{ text: string; topicId: number }> = []; let nextId = 1000; const telegram = { @@ -64,6 +64,7 @@ function fakeWorld(history: MaxHistoryMessage[] = [], onSend?: (text: string, to isWiping: () => wiping, paceMs: 5, ...(strikes ? { strikes } : {}), + ...(historyLimit ? { historyLimit } : {}), }); const retry = vi.fn(); sync.setRetryHandler(retry); @@ -159,6 +160,25 @@ describe('ChatCatchUp — catch-up on first touch', () => { expect((await store.getByMaxChatId(7))?.historyBackfillCursor).toBe('200'); }); + it('HISTORY_LIMIT: a chat filled from scratch gets only its newest messages, under a line saying why', async () => { + const live = msg(5, 500, 'm5'); + const w = fakeWorld([msg(1, 100, 'm1'), msg(2, 200, 'm2'), msg(3, 300, 'm3'), msg(4, 400, 'm4'), live], undefined, undefined, 2); + await w.livePush(8, live); + expect(w.sends.map((s) => s.text)).toEqual([historyLimitNotice(2), 'm4', 'm5']); + expect((await store.getByMaxChatId(8))?.historyBackfillCursor).toBe('500'); + }); + + it('HISTORY_LIMIT leaves a catch-up from a cursor alone, and a chat no longer than the limit gets no notice', async () => { + await store.upsert({ maxChatId: '43', telegramTopicId: 101, title: 'Б', createdAt: 'x', historyBackfillCursor: '50' }); + const live = msg(3, 300, 'c'); + const w = fakeWorld([msg(1, 100, 'a'), msg(2, 200, 'b'), live], undefined, undefined, 1); + await w.livePush(43, live); + expect(w.sends.map((s) => s.text)).toEqual(['a', 'b', 'c']); + const short = fakeWorld([msg(6, 600, 'only')], undefined, undefined, 5); + await short.livePush(44, msg(6, 600, 'only')); + expect(short.sends.map((s) => s.text)).toEqual(['only']); + }); + it('a transient failure in the catch-up leaves the cursor before the failed message and the chat out of caughtUp', async () => { await store.upsert({ maxChatId: '42', telegramTopicId: 100, title: 'Анна', createdAt: 'x', historyBackfillCursor: '50' }); let telegramDown = true; @@ -351,3 +371,23 @@ describe('ChatBannedError', () => { expect(err.name).toBe('ChatBannedError'); }); }); + +describe('HISTORY_LIMIT setting', () => { + it('reads a positive whole number, anything else means the whole history', () => { + expect(historyLimitFromEnv({ HISTORY_LIMIT: '300' })).toBe(300); + expect(historyLimitFromEnv({ HISTORY_LIMIT: ' 50 ' })).toBe(50); + expect(historyLimitFromEnv({})).toBe(0); + expect(historyLimitFromEnv({ HISTORY_LIMIT: '0' })).toBe(0); + expect(historyLimitFromEnv({ HISTORY_LIMIT: '-5' })).toBe(0); + expect(historyLimitFromEnv({ HISTORY_LIMIT: 'all' })).toBe(0); + expect(historyLimitFromEnv({ HISTORY_LIMIT: '1.5' })).toBe(0); + }); + + it('says the number the Russian way', () => { + expect(historyLimitNotice(1)).toMatch(/^📜 Здесь последнее 1 сообщение этого чата/); + expect(historyLimitNotice(3)).toMatch(/последние 3 сообщения этого/); + expect(historyLimitNotice(12)).toMatch(/последние 12 сообщений этого/); + expect(historyLimitNotice(21)).toMatch(/последнее 21 сообщение этого/); + expect(historyLimitNotice(300)).toMatch(/последние 300 сообщений этого/); + }); +}); diff --git a/tests/hints.test.ts b/tests/hints.test.ts new file mode 100644 index 0000000..a2c9b30 --- /dev/null +++ b/tests/hints.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { adminDmHintText, bridgeGroupLink } from '../src/bridge/sync.js'; +import { systemView } from '../src/bridge/panel.js'; + +describe('the bridge admin in the bot DM', () => { + it('is told where the bridge lives — the group, and /login as the one DM command', () => { + const text = adminDmHintText('Мой мост'); + expect(text).toMatch(/только вход в MAX — \/login/); + expect(text).toMatch(/в группе моста «Мой мост»:/); + expect(text).toMatch(/\/panel — пульт, в общей теме/); + expect(adminDmHintText('')).toMatch(/в группе моста:/); + }); + + it('gets a members link to the supergroup', () => { + expect(bridgeGroupLink('-1003919540013')).toBe('https://t.me/c/3919540013'); + }); +}); + +describe('«⚙️ Система» in the panel', () => { + const data = (v: ReturnType) => v.markup.inline_keyboard.flat().map((b) => ('callback_data' in b ? b.callback_data : '')); + it('offers «🔁 Перезапустить мост» and carries a note after a restart', () => { + expect(data(systemView(false))).toContain('tlmx_panel:restart'); + expect(data(systemView(true))).toContain('tlmx_panel:resume'); + expect(systemView(false).text).toBe('⚙️ Система:'); + expect(systemView(false, '✅ Мост перезапущен — всё работает.').text).toBe('⚙️ Система:\n✅ Мост перезапущен — всё работает.'); + }); +});