feat(v2.4.2): bot non-interactive install.sh actions

- install.sh: new bot_action_dispatch entry point for --action=X --json CLI invocation from the bot/scripts
- install.sh: bot_action_change_template — reuses download_template + deploy_template_to_nginx, updates config.json template_id
- install.sh: bot_action_change_lite_domain — regenerates telemt TOML with new fake-TLS mask domain, restarts telemt
- bot.py: run_bot_action() subprocess wrapper parses JSON result
- bot.py: cb_pro_confirm now performs real change-template when already in pro mode (fresh install still routes to CLI)
- bot.py: cb_lite_domain now performs real change-lite-domain when already in lite mode
- version -> 2.4.2
This commit is contained in:
anten-ka
2026-04-10 13:19:26 +03:00
parent 7b53566dad
commit fc28a1a099
3 changed files with 555 additions and 53 deletions

View File

@@ -100,13 +100,14 @@ logger = logging.getLogger(__name__)
# CONFIGURATION
# ============================================================================
GOTELEGRAM_VERSION = "2.4.0"
GOTELEGRAM_VERSION = "2.4.2"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt"
WEBSITE_ROOT = "/var/www/gotelegram-site"
BACKUP_DIR = "/opt/gotelegram/backups"
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
INSTALL_SH = "/opt/gotelegram/install.sh"
PROMO_LINK_1 = "https://vk.cc/ct29NQ"
PROMO_LINK_2 = "https://vk.cc/cUxAhj"
@@ -239,6 +240,58 @@ async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]:
return (-1, "", str(e))
async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict:
"""Invoke install.sh --action=X --json and parse the JSON result.
Args:
action: action name (e.g. "change-template", "change-lite-domain")
timeout: seconds to wait for completion (long ops: template download can take time)
**params: arbitrary key→value pairs, each passed as --key=value
Returns:
dict with at least {"status": "success|error", "message": "..."}.
Transport errors are mapped to {"status":"error","message":..., "code":"transport"}
"""
cmd = ["bash", INSTALL_SH, f"--action={action}", "--json"]
for k, v in params.items():
if v is None:
continue
cmd.append(f"--{k.replace('_', '-')}={v}")
code, stdout, stderr = await sh(*cmd, timeout=timeout)
stdout = (stdout or "").strip()
# install.sh may print multiple log lines to stderr; the JSON is on stdout.
# Pick the last non-empty line that looks like JSON (robust to any stray output).
json_line = None
for line in reversed(stdout.splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
json_line = line
break
if json_line:
try:
data = json.loads(json_line)
if isinstance(data, dict) and "status" in data:
return data
except json.JSONDecodeError as e:
logger.warning(f"run_bot_action: JSON parse failed: {e} | line={json_line!r}")
# No JSON from install.sh — synthesize an error result
tail = (stderr or "")[-300:] if stderr else ""
logger.error(
f"run_bot_action({action}): no JSON output, rc={code}, "
f"stdout={stdout[-300:]!r}, stderr={tail!r}"
)
return {
"status": "error",
"message": "install.sh did not return a JSON result",
"code": "transport",
"rc": str(code),
}
def load_json(path: str) -> Optional[Dict]:
"""Load JSON file."""
try:
@@ -808,16 +861,13 @@ async def cb_install_mode_lite(update: Update, context: ContextTypes.DEFAULT_TYP
async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Lite domain selection callback — safe stub.
"""Lite domain selection callback — real implementation (v2.4.2+).
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.
Branches on current mode:
* lite mode (active): invoke `install.sh --action=change-lite-domain`
which regenerates the telemt TOML with a new fake-TLS mask domain and
restarts the service. Preserves secret/port.
* any other mode: route to CLI. Fresh Lite install is interactive.
"""
query = update.callback_query
data = query.data
@@ -830,14 +880,52 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer()
text = (
"<b>⚠️ Установка из бота пока не поддерживается</b>\n\n"
f"Выбранный домен: <code>{html.escape(domain)}</code>\n\n"
"Чтобы установить или переключить режим Lite, запустите на сервере:\n"
"<code>gotelegram</code>\n\n"
"Затем: <b>1) Прокси → 1) Установить/Обновить → Lite</b>.\n\n"
"Существующая конфигурация <b>не была изменена</b>."
config = load_json(GOTELEGRAM_CONFIG) or {}
current_mode = config.get("mode", "")
if current_mode != "lite":
text = (
"<b>⚠️ Установка Lite из бота пока не поддерживается</b>\n\n"
f"Выбранный домен: <code>{html.escape(domain)}</code>\n\n"
"Чтобы установить Lite, запустите на сервере:\n"
"<code>gotelegram</code> → <b>1) Прокси → 1) Установить/Обновить → Lite</b>\n\n"
"Существующая конфигурация <b>не была изменена</b>."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
return
# Lite active — switch fake-TLS mask domain in place
progress_text = (
"<b>⏳ Меняю маскировочный домен...</b>\n\n"
f"Новый домен: <code>{html.escape(domain)}</code>\n\n"
"Перегенерирую конфиг telemt и перезапускаю сервис."
)
await safe_edit_message(query, progress_text, parse_mode="HTML")
result = await run_bot_action("change-lite-domain", timeout=30, domain=domain)
if result.get("status") == "success":
text = (
"<b>✅ Маскировочный домен обновлён</b>\n\n"
f"Новый домен: <code>{html.escape(domain)}</code>\n\n"
"telemt перезапущен. <b>Важно:</b> старые ссылки подключения больше "
"не будут работать — нужно заново раздать новые."
)
else:
err_msg = result.get("message", "unknown error")
err_code = result.get("code", "")
text = (
"<b>❌ Не удалось сменить домен</b>\n\n"
f"Домен: <code>{html.escape(domain)}</code>\n"
f"Причина: <code>{html.escape(err_msg)}</code>"
+ (f" (<code>{html.escape(err_code)}</code>)" if err_code else "")
+ "\n\n"
"Существующая конфигурация <b>не была изменена</b>."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
@@ -1111,19 +1199,19 @@ async def cb_pro_template(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Confirm Pro template selection — safe stub.
"""Confirm Pro template selection — real implementation (v2.4.2+).
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.
Branches on current mode:
* pro mode (active deployment): invoke `install.sh --action=change-template`
which downloads the new template and redeploys it to nginx. Reuses the
existing domain + SSL cert.
* any other mode (or no install at all): route to CLI. Fresh Pro install
still requires interactive flow (domain, email, DNS check) — not safe
to run headless from the bot.
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.
Historic context: v2.4.1 stub used to overwrite config.json with a fake
blob; that was replaced with a safe message in v2.4.1 hotfix; now in
v2.4.2 we wire the real change-template path through install.sh.
"""
query = update.callback_query
data = query.data
@@ -1131,19 +1219,63 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer()
text = (
"<b>⚠️ Установка шаблона из бота пока не поддерживается</b>\n\n"
f"Выбранный шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
"Чтобы установить или сменить шаблон, запустите на сервере:\n"
"<code>gotelegram</code>\n\n"
"Затем: <b>1) Прокси → 1) Установить/Обновить → Pro</b> "
"(или <b>7) Сменить режим/шаблон</b> для смены текущего).\n\n"
"Существующая конфигурация <b>не была изменена</b>."
# Read current config to decide: in-place change-template vs fresh install
config = load_json(GOTELEGRAM_CONFIG) or {}
current_mode = config.get("mode", "")
if current_mode != "pro":
# Fresh install / mode switch — still routes to CLI (needs domain, SSL)
text = (
"<b>⚠️ Установка Pro из бота пока не поддерживается</b>\n\n"
f"Выбранный шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
"Pro-режим требует ввода домена, email и проверки DNS. "
"Чтобы установить Pro, запустите на сервере:\n"
"<code>gotelegram</code> → <b>1) Прокси → 1) Установить/Обновить → Pro</b>\n\n"
"Существующая конфигурация <b>не была изменена</b>."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
return
# Pro mode is active — perform change-template in place
progress_text = (
"<b>⏳ Меняю шаблон сайта...</b>\n\n"
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
"Скачиваю репозиторий и разворачиваю в nginx. "
"Это может занять 3090 секунд."
)
await safe_edit_message(query, progress_text, parse_mode="HTML")
# Template download + git clone can be slow — generous timeout
result = await run_bot_action("change-template", timeout=180, template=tpl_id)
if result.get("status") == "success":
domain = result.get("domain", config.get("domain", ""))
text = (
"<b>✅ Шаблон обновлён</b>\n\n"
f"Новый шаблон: <code>{html.escape(tpl_id)}</code>\n"
f"Сайт: <a href=\"https://{html.escape(domain, quote=True)}\">https://{html.escape(domain)}</a>\n\n"
"Прокси продолжает работать без перерыва."
)
else:
err_msg = result.get("message", "unknown error")
err_code = result.get("code", "")
text = (
"<b>❌ Не удалось сменить шаблон</b>\n\n"
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n"
f"Причина: <code>{html.escape(err_msg)}</code>"
+ (f" (<code>{html.escape(err_code)}</code>)" if err_code else "")
+ "\n\n"
"Существующая конфигурация <b>не была изменена</b>. "
"Попробуйте другой шаблон или запустите <code>gotelegram</code> из консоли."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML", disable_web_page_preview=True)
# ============================================================================