fix(bot): promo auto-delete + neutralize fake install/change stubs

Two user-reported bugs:

1. Promo spam: daily promo message from /start and the Promo menu
   button stayed in chat forever. Now they auto-delete after 30s.
   - new helper _delete_message_after()
   - cmd_start schedules deletion of the daily promo
   - cb_menu_promo sends promo as a SEPARATE ephemeral message
     (instead of editing the main menu in place), so the menu stays
     intact and only the promo self-destructs in 30s.

2. CRITICAL: 'install/change template from bot' was a stub that
   silently corrupted /opt/gotelegram/config.json. cb_lite_domain
   and cb_pro_confirm wrote a fake minimal config ({mode,template,
   port,installed_at}) without secret/domain/mask_host, and showed
   '[OK] installed!' — while never invoking install.sh, never
   downloading the template, never touching nginx. Proxy link
   generation then broke because secret was gone.
   User symptom: 'устанавливал другой шаблон через бота и всё
   повисло'.

   Fix: both callbacks now refuse cleanly and route the user to the
   CLI ('gotelegram' command → menu 1 → 1 or 7). Configuration is
   NOT touched. Full non-interactive install/change from the bot is
   left as future work.

Tested live on VPS:
- bot.py syntax OK (ast.parse on VPS)
- gotelegram-bot restarted, active
- corrupted config.json on VPS rebuilt from telemt TOML ground
  truth (mode=lite, secret, port, mask_host=google.com)
This commit is contained in:
anten-ka
2026-04-10 13:00:04 +03:00
parent 6b206a1697
commit 7b53566dad

View File

@@ -288,6 +288,17 @@ async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None
raise # Re-raise unexpected BadRequest raise # Re-raise unexpected BadRequest
async def _delete_message_after(message, delay: int = 30) -> None:
"""Delete a Telegram message after `delay` seconds. Errors are swallowed
(message may already be deleted by the user). Used for ephemeral content
like promo blocks that should auto-cleanup."""
try:
await asyncio.sleep(delay)
await message.delete()
except Exception as e:
logger.debug(f"_delete_message_after: {e}")
async def check_service_status(service: str) -> bool: async def check_service_status(service: str) -> bool:
"""Check if systemd service is running.""" """Check if systemd service is running."""
code, _, _ = await sh("systemctl", "is-active", service) code, _, _ = await sh("systemctl", "is-active", service)
@@ -465,12 +476,13 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML" welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML"
) )
# Промо раз в сутки # Промо раз в сутки — сообщение само удаляется через 30 секунд
if should_show_promo_bot(): if should_show_promo_bot():
mark_promo_shown_bot() mark_promo_shown_bot()
await update.message.reply_text( promo_msg = await update.message.reply_text(
get_promo_text(), parse_mode="HTML" get_promo_text(), parse_mode="HTML", disable_web_page_preview=True
) )
asyncio.create_task(_delete_message_after(promo_msg, 30))
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -796,7 +808,17 @@ async def cb_install_mode_lite(update: Update, context: ContextTypes.DEFAULT_TYP
async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Lite domain selection callback.""" """Lite domain selection callback — safe stub.
Historic bug (v2.4.1): this callback used to overwrite
/opt/gotelegram/config.json with a minimal fake config (no secret, no
mask_host, no engine), silently corrupting the live proxy and showing a
fake "✅ Lite mode installed!" — while never actually invoking install.sh.
Installing/changing modes non-interactively from the bot is not yet wired
up. Until it is, refuse cleanly and route the user to the CLI instead of
damaging the working configuration.
"""
query = update.callback_query query = update.callback_query
data = query.data data = query.data
try: try:
@@ -807,36 +829,19 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
return return
await query.answer() await query.answer()
await safe_edit_message(query,f"⏳ Installing with domain: {domain}...")
# Simulate installation (in real scenario, call install script) text = (
config = { "<b>⚠️ Установка из бота пока не поддерживается</b>\n\n"
"mode": "lite", f"Выбранный домен: <code>{html.escape(domain)}</code>\n\n"
"domain": domain, "Чтобы установить или переключить режим Lite, запустите на сервере:\n"
"port": 443, "<code>gotelegram</code>\n\n"
"installed_at": datetime.now().isoformat(), "Затем: <b>1) Прокси → 1) Установить/Обновить → Lite</b>.\n\n"
} "Существующая конфигурация <b>не была изменена</b>."
)
if save_json(GOTELEGRAM_CONFIG, config): keyboard = InlineKeyboardMarkup(
text = ( [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
f"✅ <b>Lite mode installed!</b>\n\n" )
f"<b>Domain:</b> {domain}\n" await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
f"<b>Mode:</b> Lite\n\n"
f"Service starting... Check status in 10 seconds."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
)
else:
await safe_edit_message(query,
"❌ Failed to save configuration",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton("« Back", callback_data="menu_install")]]
),
)
async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -1106,41 +1111,39 @@ async def cb_pro_template(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Confirm and install pro template.""" """Confirm Pro template selection — safe stub.
Historic bug (v2.4.1): this callback used to overwrite
/opt/gotelegram/config.json with a fake {"mode":"pro","template":...,
"port":443} blob — no secret, no domain, no mask_host — silently
corrupting the live proxy so link generation and status broke. At the
same time the user saw "✅ Pro mode installed!" although install.sh was
never invoked, no template was actually downloaded, nginx was not
reconfigured and certbot was not touched.
Non-interactive Pro install/change from the bot is not yet implemented.
Until it is, refuse cleanly and route the user to the CLI instead of
damaging the working configuration.
"""
query = update.callback_query query = update.callback_query
data = query.data data = query.data
tpl_id = data.removeprefix("pro_confirm_") tpl_id = data.removeprefix("pro_confirm_")
await query.answer() await query.answer()
await safe_edit_message(query,"⏳ Installing template...")
config = { text = (
"mode": "pro", "<b>⚠️ Установка шаблона из бота пока не поддерживается</b>\n\n"
"template": tpl_id, f"Выбранный шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
"port": 443, "Чтобы установить или сменить шаблон, запустите на сервере:\n"
"installed_at": datetime.now().isoformat(), "<code>gotelegram</code>\n\n"
} "Затем: <b>1) Прокси → 1) Установить/Обновить → Pro</b> "
"(или <b>7) Сменить режим/шаблон</b> для смены текущего).\n\n"
if save_json(GOTELEGRAM_CONFIG, config): "Существующая конфигурация <b>не была изменена</b>."
text = ( )
f"✅ <b>Pro mode installed!</b>\n\n" keyboard = InlineKeyboardMarkup(
f"<b>Template:</b> {html.escape(tpl_id)}\n" [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
f"<b>Mode:</b> Pro\n\n" )
f"Service starting... Check status in 10 seconds." await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
)
else:
await safe_edit_message(query,
"❌ Failed to save configuration",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton("« Back", callback_data="menu_install")]]
),
)
# ============================================================================ # ============================================================================
@@ -1799,14 +1802,16 @@ def mark_promo_shown_bot() -> None:
async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Promo information — always shown from menu.""" """Promo information — shown as a separate ephemeral message that
auto-deletes after 30s so it does not clutter the chat. The main menu
message stays intact (we don't edit it in place)."""
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
keyboard = InlineKeyboardMarkup( promo_msg = await query.message.reply_text(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] get_promo_text(), parse_mode="HTML", disable_web_page_preview=True
) )
await safe_edit_message(query, get_promo_text(), reply_markup=keyboard, parse_mode="HTML") asyncio.create_task(_delete_message_after(promo_msg, 30))
async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: