1.3.7-beta.1: сторож Telegram — запрос новых сообщений висит дольше 2 минут (у Telegraf таймаут 500 с) → обрыв и переподключение, повторно за 10 минут → перезапуск контейнера, отчёт telegram-poll-stall; «🔁 Перезапустить мост» в пульте → Система (без пересборки, пульт возвращается сам); админу моста в личке — подсказка «команды в группе» с ссылкой вместо заглушки баг-бота; HISTORY_LIMIT — при первой синхронизации только последние N сообщений чата, с пометкой в теме, вопрос в установщике, строка в меню настроек

This commit is contained in:
Folist 2026-10-13 12:49:31 +03:00
parent 7e50dcd810
commit e6705ad0ed
10 changed files with 322 additions and 14 deletions

View file

@ -48,6 +48,8 @@ export interface ControlPanelDeps {
offerStore?: (panelMessageId: number) => Promise<void>;
/** «📤 Отправить в 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(() => {});

View file

@ -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<MaxHistoryMessage[]> {
limit = 0,
): Promise<{ messages: MaxHistoryMessage[]; truncated: boolean }> {
const all: MaxHistoryMessage[] = [];
const seenIds = new Set<string>();
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/<id without -100>. */
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<void> {
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<boolean> {
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<boolean> {
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),

View file

@ -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<void> {
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<void> {
// 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<unknown>)(method, payload, extra);
pollInFlightSince = Date.now();
return (callApi as (m: string, p: unknown, e?: unknown) => Promise<unknown>)(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<void> {
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<void> {
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<v
bot.telegram.setMyDescription(buildBotDescription()).catch((err) => 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