From 9ff1c3e7a8f024f13b215c0d64297a58968c7c6e Mon Sep 17 00:00:00 2001 From: Folist Date: Sun, 11 Oct 2026 05:18:59 +0300 Subject: [PATCH] =?UTF-8?q?1.3.2-beta.3:=20=D0=B2=D1=81=D1=91,=20=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=B8=D1=82=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=D0=BB=D0=B8=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82?= =?UTF-8?q?=D1=8C,=20=D0=BF=D0=BE=D1=8F=D0=B2=D0=BB=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BC=20=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B5=20=E2=80=94=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=20=D0=B1=D0=B0=D0=BD=D0=B0/=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B1=D0=B0=D0=BD=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=20=C2=AB=D0=9E=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=C2=BB,=20=C2=AB=D0=9A=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=C2=BB,=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=C2=AB=F0=9F=93=A4=20=D0=97=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=B8=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=C2=BB=20=D0=B8=20=D0=B5=D1=91=20=D0=B8=D1=82=D0=BE=D0=B3;=20?= =?UTF-8?q?=D1=83=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=81=D1=82=D0=B0=D1=91=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=C2=AB=E2=97=80=EF=B8=8F=20=D0=9D=D0=B0=D0=B7=D0=B0=D0=B4=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/bigFiles.ts | 46 +++++++++++++++++++++++---------------- src/bridge/panel.ts | 48 +++++++++++++++++++++++++++-------------- src/bridge/panelBack.ts | 16 ++++++++++++++ src/bridge/sync.ts | 48 ++++++++++++++++++++--------------------- tests/panelBack.test.ts | 22 +++++++++++++++++++ 7 files changed, 123 insertions(+), 63 deletions(-) create mode 100644 src/bridge/panelBack.ts create mode 100644 tests/panelBack.test.ts diff --git a/package-lock.json b/package-lock.json index d2d4857..60c3dff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 770f17a..a2cf3fc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bridge/bigFiles.ts b/src/bridge/bigFiles.ts index 2a88502..faab7e3 100644 --- a/src/bridge/bigFiles.ts +++ b/src/bridge/bigFiles.ts @@ -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; /** MAX → Telegram: a FILE over the bot upload limit — save it and post a download link. */ relayFromMax(att: MaxAttachment, ctx: DownloadContext, opts: SendOpts): Promise; - /** The panel's «📤 Загрузить файл»: an upload link into the server's own store, posted in General. */ - offerStore(): Promise; + /** The panel's «📤 Загрузить файл»: an upload link into the server's own store, drawn in the panel message. */ + offerStore(panelMessageId: number): Promise; /** 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[0]): Promise { 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 { + /** + * 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 { + const show = (text: string, rows: ReturnType = []) => 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; } diff --git a/src/bridge/panel.ts b/src/bridge/panel.ts index 8a5b919..8529453 100644 --- a/src/bridge/panel.ts +++ b/src/bridge/panel.ts @@ -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; +/** 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; export interface ControlPanelDeps { bot: Telegraf; @@ -20,12 +22,12 @@ export interface ControlPanelDeps { max: MaxClient; getActivePhone: () => string; triggerFullResync: () => Promise; - /** 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; + /** «📤 Загрузить файл»: an upload link into the server store, drawn in the panel message (bigFiles.ts). */ + offerStore?: (panelMessageId: number) => Promise; /** «📤 Отправить в 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 { + 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 diff --git a/src/bridge/panelBack.ts b/src/bridge/panelBack.ts new file mode 100644 index 0000000..30f0067 --- /dev/null +++ b/src/bridge/panelBack.ts @@ -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 } : {}; +} diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index f3f75b6..e75900b 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -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}`)]) }; }, }, }); diff --git a/tests/panelBack.test.ts b/tests/panelBack.test.ts new file mode 100644 index 0000000..f07956f --- /dev/null +++ b/tests/panelBack.test.ts @@ -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({}); + }); +});