1.3.2-beta.3: всё, что пульт просит выбрать или нажать, появляется в самом пульте — списки бана/разбана, проверка обновления с «Обновить», «Команды», ссылка «📤 Загрузить файл» и её итог; у результата остаётся «◀️ Назад»

This commit is contained in:
Folist 2026-10-11 05:18:59 +03:00
parent 5dc919da9d
commit 9ff1c3e7a8
7 changed files with 123 additions and 63 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telemax",
"version": "1.3.2-beta.2",
"version": "1.3.2-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "telemax",
"version": "1.3.2-beta.2",
"version": "1.3.2-beta.3",
"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.2-beta.2",
"version": "1.3.2-beta.3",
"type": "module",
"scripts": {
"dev": "tsx watch src/server/app.ts",

View file

@ -12,6 +12,7 @@ import { formatBytes } from './status.js';
import { uploadFileFromDiskToMax } from './upload.js';
import { isTransientHttpStatus, isTransientNetworkError, TransientDownloadError } from './transient.js';
import { reportError } from './telemetry.js';
import { keepPanelBack } from './panelBack.js';
/**
* What the topic is told about a failure: MAX's own answers are readable Russian and stay as they
@ -62,8 +63,8 @@ export interface BigFiles {
offerUpload(p: { maxChatId: string; topicId: number; telegramMessageId: number; caption?: string; name?: string; size?: number }): Promise<void>;
/** MAX → Telegram: a FILE over the bot upload limit — save it and post a download link. */
relayFromMax(att: MaxAttachment, ctx: DownloadContext, opts: SendOpts): Promise<SentMessage>;
/** The panel's «📤 Загрузить файл»: an upload link into the server's own store, posted in General. */
offerStore(): Promise<void>;
/** The panel's «📤 Загрузить файл»: an upload link into the server's own store, drawn in the panel message. */
offerStore(panelMessageId: number): Promise<void>;
/** The panel's «📤 Отправить в MAX»: a stored file that didn't make it, sent again from disk. */
resendToMax(id: string): Promise<{ ok: boolean; text: string }>;
}
@ -117,8 +118,10 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
// --- Telegram → MAX -------------------------------------------------------------------------
const CANCEL = 'tlmx_panel:fcancel:';
const uploadKeyboard = (url: string, token: string) =>
Markup.inlineKeyboard([[Markup.button.url('📤 Загрузить файл', url)], [Markup.button.callback('✖️ Отменить', CANCEL + token)]]);
const uploadRows = (url: string, token: string) => [[Markup.button.url('📤 Загрузить файл', url)], [Markup.button.callback('✖️ Отменить', CANCEL + token)]];
const uploadKeyboard = (url: string, token: string) => Markup.inlineKeyboard(uploadRows(url, token));
/** The way back to «📁 Файлы» for everything the file exchange draws in the panel message. */
const TO_FILES = [Markup.button.callback('◀️ К файлам', 'tlmx_panel:files')];
async function offerUpload(p: Parameters<BigFiles['offerUpload']>[0]): Promise<void> {
const fromTelegram = p.name != null && p.size != null;
@ -186,43 +189,48 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
if (ticket) logger.info(`Upload link for «${ticket.name || 'any file'}» cancelled`);
if (ticket) await fileShare.dropTicket(ticket.token);
const text = (ctx.callbackQuery.message as { text?: string } | undefined)?.text;
await ctx.editMessageText(ticket || !text ? '✖️ Загрузка отменена — ссылка больше не работает.' : text).catch(() => {});
// In the panel (the file exchange) the cancelled link keeps «◀️ К файлам».
await ctx.editMessageText(ticket || !text ? '✖️ Загрузка отменена — ссылка больше не работает.' : text, keepPanelBack(ctx)).catch(() => {});
});
async function offerStore(): Promise<void> {
/**
* The panel's «📤 Загрузить файл», drawn IN the panel message: the admin is looking at the panel,
* and a link posted at the bottom of the chat went unseen (2026-10-11). The finished upload lands
* in the same message, with «◀️ К файлам».
*/
async function offerStore(panelId: number): Promise<void> {
const show = (text: string, rows: ReturnType<typeof uploadRows> = []) => edit(panelId, 0, text, Markup.inlineKeyboard([...rows, TO_FILES]));
if (!fileShare.enabled) {
await send('⚠️ Пересылка больших файлов отключена в настройках (FILES=off).', {});
await show('⚠️ Пересылка больших файлов отключена в настройках (FILES=off).');
return;
}
const room = await fileShare.roomFor(1);
if (!room.fits) {
await send(`⚠️ Загрузить файл не выйдет: на сервере свободно ${formatBytes(room.freeBytes)}, а ${reserveText} нужно оставить для обновлений.`, {});
await show(`⚠️ Загрузить файл не выйдет: на сервере свободно ${formatBytes(room.freeBytes)}, а ${reserveText} нужно оставить для обновлений.`);
return;
}
const prompt = await send('⏳ Готовлю ссылку для загрузки файла на сервер…', {});
await show('⏳ Готовлю ссылку для загрузки файла на сервер…');
const ticket = await fileShare.createUploadTicket({
purpose: 'store',
maxChatId: '',
topicId: 0,
telegramMessageId: prompt.message_id,
telegramMessageId: panelId,
name: '',
expectedSize: 0,
promptMessageId: prompt.message_id,
promptMessageId: panelId,
});
logger.info('Panel: offering an upload link into the server store');
const svc = await fileShare.ensureService();
if (!svc.ok) {
report('service', svc.reason);
await fileShare.dropTicket(ticket.token);
await edit(prompt.message_id, 0, `⚠️ Ссылку для загрузки подготовить не удалось — ${svc.reason}.`);
await show(`⚠️ Ссылку для загрузки подготовить не удалось — ${svc.reason}.`);
return;
}
await edit(
prompt.message_id,
0,
`📤 Загрузите файл по ссылке — он сохранится на вашем сервере, а ссылку для скачивания я пришлю сюда же. Ею можно делиться где угодно.
await show(
`📤 Загрузите файл по ссылке — он сохранится на вашем сервере, а ссылка для скачивания появится здесь же. Ею можно делиться где угодно.
Ссылка на загрузку действует до ${formatExpiry(ticket.expiresAt)}.`,
uploadKeyboard(`${svc.url}/f/${ticket.token}`, ticket.token),
uploadRows(`${svc.url}/f/${ticket.token}`, ticket.token),
);
}
@ -234,7 +242,7 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
const svc = await fileShare.ensureService(30_000);
const until = formatExpiry(file.expiresAt);
if (!svc.ok) {
await edit(ticket.promptMessageId, 0, `✅ Файл ${label} на сервере до ${until}. Ссылку можно взять в пульте → 📁 Файлы.`);
await edit(ticket.promptMessageId, 0, `✅ Файл ${label} на сервере до ${until}. Ссылку можно взять в пульте → 📁 Файлы.`, Markup.inlineKeyboard([TO_FILES]));
return;
}
await edit(
@ -243,7 +251,7 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
`✅ Файл ${label} на сервере до ${until}.
🔗 ${svc.url}/f/${file.token}
Ссылкой можно делиться — по ней откроется страница со скачиванием. Продлить или удалить — в пульте → 📁 Файлы.`,
Markup.inlineKeyboard([Markup.button.url('⬇️ Скачать', `${svc.url}/f/${file.token}`)]),
Markup.inlineKeyboard([[Markup.button.url('⬇️ Скачать', `${svc.url}/f/${file.token}`)], TO_FILES]),
);
return;
}

View file

@ -1,6 +1,7 @@
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { Markup, type Telegraf, type Context } from 'telegraf';
import type { InlineKeyboardButton } from 'telegraf/types';
import type { MaxClient, MaxContactInfo } from '../max/client.js';
import { getAppVersion } from './version.js';
import { formatBytes, maskPhone } from './status.js';
@ -11,8 +12,9 @@ import type { FileShare, StoredFile } from './fileShare.js';
const logger = createLogger('panel');
/** Sends one of the reused command outputs (help, version, ban/unban list) to a chat. */
type LeafSender = (chatId: number) => Promise<void>;
/** One of the reused command outputs (help, version, ban/unban list): text plus its own button rows. The panel draws it in place and adds «◀️ Назад». */
export type LeafView = { text: string; rows: InlineKeyboardButton[][] };
type LeafBuilder = () => Promise<LeafView>;
export interface ControlPanelDeps {
bot: Telegraf;
@ -20,12 +22,12 @@ export interface ControlPanelDeps {
max: MaxClient;
getActivePhone: () => string;
triggerFullResync: () => Promise<void>;
/** Reused slash-command bodies — the panel just triggers the same output. */
/** Reused slash-command bodies — the panel shows the same output in its own message. */
leaves: {
sendHelp: LeafSender;
sendVersion: LeafSender;
sendBanList: LeafSender;
sendUnbanList: LeafSender;
help: LeafBuilder;
version: LeafBuilder;
banList: LeafBuilder;
unbanList: LeafBuilder;
};
/** Reuses or creates a MAX dialog with the contact and its Telegram topic; returns a deep link to open it. */
startDialog: (
@ -42,8 +44,8 @@ export interface ControlPanelDeps {
pause: PauseControl;
/** Big files kept for download links — the «📁 Файлы» section. */
files?: FileShare;
/** «📤 Загрузить файл»: an upload link into the server store, posted in General (bigFiles.ts). */
offerStore?: () => Promise<void>;
/** «📤 Загрузить файл»: an upload link into the server store, drawn in the panel message (bigFiles.ts). */
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 }>;
}
@ -433,7 +435,8 @@ export function wireControlPanel(deps: ControlPanelDeps): void {
});
bot.action('tlmx_panel:fupload', async (ctx) => {
await ctx.answerCbQuery('Готовлю ссылку для загрузки…').catch(() => {});
if (offerStore) void offerStore().catch((err) => logger.error('Panel store upload failed', err));
const panelId = ctx.callbackQuery.message?.message_id;
if (offerStore && panelId != null) void offerStore(panelId).catch((err) => logger.error('Panel store upload failed', err));
});
bot.action(/^tlmx_panel:fdel:(.+)$/, async (ctx) => {
const name = files?.get(ctx.match[1] ?? '')?.name;
@ -448,22 +451,35 @@ export function wireControlPanel(deps: ControlPanelDeps): void {
await ctx.deleteMessage().catch(() => {});
});
// --- Leaves that spawn their own message (reuse the slash-command output) -----------
// --- Leaves: the slash-command output, drawn IN the panel ---------------------------
// The admin is looking at the panel (often scrolled up to the pin): a list or a button posted at
// the bottom of the chat went unseen (2026-10-11). «◀️ Назад» returns to the menu, and a press
// inside the leaf keeps it (keepPanelBack in panelBack.ts).
async function showLeaf(ctx: Context, build: LeafBuilder, back: string): Promise<void> {
let view: LeafView;
try {
view = await build();
} catch (err) {
logger.error('Panel leaf failed', err);
view = { text: '❌ Не получилось — смотрите логи контейнера.', rows: [] };
}
await edit(ctx, { text: view.text, markup: Markup.inlineKeyboard([...view.rows, [Markup.button.callback('◀️ Назад', back)]]).reply_markup });
}
bot.action('tlmx_panel:help', async (ctx) => {
await ctx.answerCbQuery();
await leaves.sendHelp(chatIdOf(ctx));
await showLeaf(ctx, leaves.help, 'tlmx_panel:system');
});
bot.action('tlmx_panel:update', async (ctx) => {
await ctx.answerCbQuery();
await leaves.sendVersion(chatIdOf(ctx));
await ctx.answerCbQuery('Проверяю обновления…');
await showLeaf(ctx, leaves.version, 'tlmx_panel:system');
});
bot.action('tlmx_panel:ban', async (ctx) => {
await ctx.answerCbQuery();
await leaves.sendBanList(chatIdOf(ctx));
await showLeaf(ctx, leaves.banList, 'tlmx_panel:chats');
});
bot.action('tlmx_panel:unban', async (ctx) => {
await ctx.answerCbQuery();
await leaves.sendUnbanList(chatIdOf(ctx));
await showLeaf(ctx, leaves.unbanList, 'tlmx_panel:chats');
});
bot.action('tlmx_panel:resync', async (ctx) => {
// Without a MAX session CHATS_LIST is rejected ("Недопустимое состояние сессии") and the only

16
src/bridge/panelBack.ts Normal file
View file

@ -0,0 +1,16 @@
import { Markup, type Context } from 'telegraf';
import type { InlineKeyboardButton, InlineKeyboardMarkup } from 'telegraf/types';
/**
* The panel's own «◀️ …» button on the message a button was pressed in, kept for the result. The panel
* draws lists and prompts (ban/unban, version, the upload link) IN its own message, so a press that
* rewrites that message must leave a way back — or the pinned panel ends up a dead text with no
* buttons. The same lists sent by /ban, /version… carry no such button, and their results stay bare.
*/
export function keepPanelBack(ctx: Context): { reply_markup?: InlineKeyboardMarkup } {
const rows = (ctx.callbackQuery?.message as { reply_markup?: InlineKeyboardMarkup } | undefined)?.reply_markup?.inline_keyboard ?? [];
const back = rows
.flat()
.find((b): b is InlineKeyboardButton.CallbackButton => 'callback_data' in b && b.callback_data.startsWith('tlmx_panel:') && b.text.startsWith('◀️'));
return back ? { reply_markup: Markup.inlineKeyboard([[Markup.button.callback(back.text, back.callback_data)]]).reply_markup } : {};
}

View file

@ -29,6 +29,7 @@ import { createBigFiles } from './bigFiles.js';
import { canRenderAnimatedStickers } from './lottie.js';
import { reportBridgeError } from './errorReporter.js';
import { wireControlPanel, type PauseControl } from './panel.js';
import { keepPanelBack } from './panelBack.js';
import { createBugReports, isBugReportInboxEnabled, BUGREPORT_BOT_HANDLE, type BugReports } from './bugReports.js';
import { createMaxAuthFlow, type MaxAuthCallbacks } from './maxAuthFlow.js';
import { createTelemetry } from './telemetry.js';
@ -2631,9 +2632,11 @@ export function wireBridge({
// A fresh /version still shows a live «Обновить» button mid-update (the container isn't
// rebuilt yet). update-requested = queued for the host watcher; update-in-progress = update.sh
// is running. flock in update-watcher.sh is the hard backstop; this is the friendly heads-up.
// Pressed in the panel, the result keeps its «◀️ Назад» (keepPanelBack).
const back = keepPanelBack(ctx);
if ((await markerActive(UPDATE_REQUESTED_MARKER)) || (await markerActive(UPDATE_IN_PROGRESS_MARKER))) {
await ctx.answerCbQuery('Обновление уже идёт');
await ctx.editMessageText('⏳ Обновление уже запущено — дождитесь сообщения о завершении.').catch(() => {});
await ctx.editMessageText('⏳ Обновление уже запущено — дождитесь сообщения о завершении.', back).catch(() => {});
return;
}
// The marker is consumed only by the host dispatcher (absent when setup.sh ran without root):
@ -2641,17 +2644,17 @@ export function wireBridge({
if (!(await watcherAlive())) {
await ctx.answerCbQuery('Автообновление не настроено');
await ctx
.editMessageText('⚠️ Автообновление на этом мосту не настроено — запрос некому выполнить.\nОбновите на сервере: cd <каталог моста> && ./update.sh\n(автообновление ставится запуском ./setup.sh от root)')
.editMessageText('⚠️ Автообновление на этом мосту не настроено — запрос некому выполнить.\nОбновите на сервере: cd <каталог моста> && ./update.sh\n(автообновление ставится запуском ./setup.sh от root)', back)
.catch(() => {});
return;
}
await ctx.answerCbQuery('Обновление запрошено');
try {
await writeFile(UPDATE_REQUESTED_MARKER, new Date().toISOString(), 'utf8');
await ctx.editMessageText('⏳ Обновление запрошено — история переписки не затрагивается.');
await ctx.editMessageText('⏳ Обновление запрошено — история переписки не затрагивается.', back);
} catch (err) {
logger.error('Failed to write update-requested marker', err);
await ctx.editMessageText('❌ Не удалось запросить обновление — смотрите логи контейнера.');
await ctx.editMessageText('❌ Не удалось запросить обновление — смотрите логи контейнера.', back);
}
});
@ -2700,7 +2703,7 @@ export function wireBridge({
bot.action('tlmx_dismiss', async (ctx) => {
await ctx.answerCbQuery('Ок');
await ctx.editMessageText('⏰ Отложено — напомню при следующей ежедневной проверке.');
await ctx.editMessageText('⏰ Отложено — напомню при следующей ежедневной проверке.', keepPanelBack(ctx));
});
// /ban — mute a MAX chat: its messages stop being mirrored and its topic is deleted. Persistent,
@ -2729,7 +2732,7 @@ export function wireBridge({
await deleteTopic(mapping.telegramTopicId, '/ban');
}
await ctx.answerCbQuery('Забанен');
await ctx.editMessageText(`🚫 Забанен: ${mapping?.title ?? maxChatId}. Сообщения больше не приходят. Вернуть — /unban.`).catch(() => {});
await ctx.editMessageText(`🚫 Забанен: ${mapping?.title ?? maxChatId}. Сообщения больше не приходят. Вернуть — /unban.`, keepPanelBack(ctx)).catch(() => {});
});
// /unban — flips the flag; the topic comes back on the next incoming message from that chat.
@ -2752,7 +2755,7 @@ export function wireBridge({
const mapping = await chatMapStore.getByMaxChatId(maxChatId);
await chatMapStore.setBanned(maxChatId, false);
await ctx.answerCbQuery('Разбанен');
await ctx.editMessageText(`♻️ Разбанен: ${mapping?.title ?? maxChatId}. Тема вернётся при следующем сообщении от него.`).catch(() => {});
await ctx.editMessageText(`♻️ Разбанен: ${mapping?.title ?? maxChatId}. Тема вернётся при следующем сообщении от него.`, keepPanelBack(ctx)).catch(() => {});
});
// --- Control panel (src/bridge/panel.ts): a pinned inline-button menu in General. The leaves
@ -2831,7 +2834,7 @@ export function wireBridge({
pause,
files: fileShare,
resendToMax: (id) => bigFiles.resendToMax(id),
offerStore: () => bigFiles.offerStore(),
offerStore: (panelMessageId) => bigFiles.offerStore(panelMessageId),
// Warm-cache name lookup (buildRoster already fetched the profiles); undefined on a miss lets
// the panel fall back to CONTACT_INFO.
resolveContactName: (uid) => {
@ -2865,28 +2868,23 @@ export function wireBridge({
});
},
leaves: {
sendHelp: (chatId) => bot.telegram.sendMessage(chatId, buildHelpText()).then(() => {}),
sendVersion: async (chatId) => {
help: async () => ({ text: buildHelpText(), rows: [] }),
version: async () => {
const { text, replyMarkup } = formatVersionMessage(await checkVersion());
await bot.telegram.sendMessage(chatId, text, { reply_markup: replyMarkup });
return { text, rows: replyMarkup?.inline_keyboard ?? [] };
},
sendBanList: async (chatId) => {
banList: async () => {
const active = (await chatMapStore.list()).filter((m) => !m.banned);
if (active.length === 0) {
await bot.telegram.sendMessage(chatId, 'Нет активных чатов для бана.');
return;
}
const buttons = active.slice(0, 90).map((m) => Markup.button.callback(m.title || `MAX chat ${m.maxChatId}`, `tlmx_ban:${m.maxChatId}`));
await bot.telegram.sendMessage(chatId, '🚫 Кого забанить?', { reply_markup: Markup.inlineKeyboard(buttons, { columns: 1 }).reply_markup });
if (active.length === 0) return { text: 'Нет активных чатов для бана.', rows: [] };
return {
text: '🚫 Кого забанить? Сообщения от выбранного чата приходить перестанут, его тема удалится.',
rows: active.slice(0, 90).map((m) => [Markup.button.callback(m.title || `MAX chat ${m.maxChatId}`, `tlmx_ban:${m.maxChatId}`)]),
};
},
sendUnbanList: async (chatId) => {
unbanList: async () => {
const banned = (await chatMapStore.list()).filter((m) => m.banned);
if (banned.length === 0) {
await bot.telegram.sendMessage(chatId, 'Забаненных чатов нет.');
return;
}
const buttons = banned.slice(0, 90).map((m) => Markup.button.callback(m.title || `MAX chat ${m.maxChatId}`, `tlmx_unban:${m.maxChatId}`));
await bot.telegram.sendMessage(chatId, '♻️ Кого разбанить?', { reply_markup: Markup.inlineKeyboard(buttons, { columns: 1 }).reply_markup });
if (banned.length === 0) return { text: 'Забаненных чатов нет.', rows: [] };
return { text: '♻️ Кого разбанить?', rows: banned.slice(0, 90).map((m) => [Markup.button.callback(m.title || `MAX chat ${m.maxChatId}`, `tlmx_unban:${m.maxChatId}`)]) };
},
},
});

22
tests/panelBack.test.ts Normal file
View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import type { Context } from 'telegraf';
import { keepPanelBack } from '../src/bridge/panelBack.js';
const pressedIn = (inline_keyboard: unknown[][]) => ({ callbackQuery: { message: { reply_markup: { inline_keyboard } } } }) as unknown as Context;
describe('keepPanelBack', () => {
it('keeps the panel «◀️ Назад» of a list drawn in the panel', () => {
const ctx = pressedIn([[{ text: 'Чат 1', callback_data: 'tlmx_ban:1' }], [{ text: '◀️ Назад', callback_data: 'tlmx_panel:chats' }]]);
expect(keepPanelBack(ctx)).toEqual({ reply_markup: { inline_keyboard: [[{ text: '◀️ Назад', callback_data: 'tlmx_panel:chats', hide: false }]] } });
});
it('keeps «◀️ К файлам» under an upload link in the panel', () => {
const ctx = pressedIn([[{ text: '📤 Загрузить файл', url: 'https://x/f/t' }], [{ text: '✖️ Отменить', callback_data: 'tlmx_panel:fcancel:t' }], [{ text: '◀️ К файлам', callback_data: 'tlmx_panel:files' }]]);
expect(keepPanelBack(ctx).reply_markup?.inline_keyboard).toEqual([[{ text: '◀️ К файлам', callback_data: 'tlmx_panel:files', hide: false }]]);
});
it('adds nothing to the lists sent by /ban, /version or an upload link in a topic', () => {
expect(keepPanelBack(pressedIn([[{ text: 'Чат 1', callback_data: 'tlmx_ban:1' }]]))).toEqual({});
expect(keepPanelBack(pressedIn([[{ text: '🔄 Обновить', callback_data: 'tlmx_update' }, { text: '⏰ Позже', callback_data: 'tlmx_dismiss' }]]))).toEqual({});
expect(keepPanelBack(pressedIn([[{ text: '📤 Загрузить файл', url: 'https://x/f/t' }], [{ text: '✖️ Отменить', callback_data: 'tlmx_panel:fcancel:t' }]]))).toEqual({});
expect(keepPanelBack({} as Context)).toEqual({});
});
});