From fc28a1a09958b73661c9a44e35e12ed8bd20efb2 Mon Sep 17 00:00:00 2001 From: anten-ka Date: Fri, 10 Apr 2026 13:19:26 +0300 Subject: [PATCH] feat(v2.4.2): bot non-interactive install.sh actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- gotelegram-bot/bot.py | 206 ++++++++++++++++++++++++++++------- install.sh | 246 ++++++++++++++++++++++++++++++++++++++++++ lib/common.sh | 156 ++++++++++++++++++++++++--- 3 files changed, 555 insertions(+), 53 deletions(-) mode change 100644 => 100755 lib/common.sh diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 1d63852..408b64f 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -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 = ( - "⚠️ Установка из бота пока не поддерживается\n\n" - f"Выбранный домен: {html.escape(domain)}\n\n" - "Чтобы установить или переключить режим Lite, запустите на сервере:\n" - "gotelegram\n\n" - "Затем: 1) Прокси → 1) Установить/Обновить → Lite.\n\n" - "Существующая конфигурация не была изменена." + config = load_json(GOTELEGRAM_CONFIG) or {} + current_mode = config.get("mode", "") + + if current_mode != "lite": + text = ( + "⚠️ Установка Lite из бота пока не поддерживается\n\n" + f"Выбранный домен: {html.escape(domain)}\n\n" + "Чтобы установить Lite, запустите на сервере:\n" + "gotelegram1) Прокси → 1) Установить/Обновить → Lite\n\n" + "Существующая конфигурация не была изменена." + ) + 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 = ( + "⏳ Меняю маскировочный домен...\n\n" + f"Новый домен: {html.escape(domain)}\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 = ( + "✅ Маскировочный домен обновлён\n\n" + f"Новый домен: {html.escape(domain)}\n\n" + "telemt перезапущен. Важно: старые ссылки подключения больше " + "не будут работать — нужно заново раздать новые." + ) + else: + err_msg = result.get("message", "unknown error") + err_code = result.get("code", "") + text = ( + "❌ Не удалось сменить домен\n\n" + f"Домен: {html.escape(domain)}\n" + f"Причина: {html.escape(err_msg)}" + + (f" ({html.escape(err_code)})" if err_code else "") + + "\n\n" + "Существующая конфигурация не была изменена." + ) + 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 = ( - "⚠️ Установка шаблона из бота пока не поддерживается\n\n" - f"Выбранный шаблон: {html.escape(tpl_id)}\n\n" - "Чтобы установить или сменить шаблон, запустите на сервере:\n" - "gotelegram\n\n" - "Затем: 1) Прокси → 1) Установить/Обновить → Pro " - "(или 7) Сменить режим/шаблон для смены текущего).\n\n" - "Существующая конфигурация не была изменена." + # 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 = ( + "⚠️ Установка Pro из бота пока не поддерживается\n\n" + f"Выбранный шаблон: {html.escape(tpl_id)}\n\n" + "Pro-режим требует ввода домена, email и проверки DNS. " + "Чтобы установить Pro, запустите на сервере:\n" + "gotelegram1) Прокси → 1) Установить/Обновить → Pro\n\n" + "Существующая конфигурация не была изменена." + ) + 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 = ( + "⏳ Меняю шаблон сайта...\n\n" + f"Шаблон: {html.escape(tpl_id)}\n\n" + "Скачиваю репозиторий и разворачиваю в nginx. " + "Это может занять 30–90 секунд." ) + 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 = ( + "✅ Шаблон обновлён\n\n" + f"Новый шаблон: {html.escape(tpl_id)}\n" + f"Сайт: https://{html.escape(domain)}\n\n" + "Прокси продолжает работать без перерыва." + ) + else: + err_msg = result.get("message", "unknown error") + err_code = result.get("code", "") + text = ( + "❌ Не удалось сменить шаблон\n\n" + f"Шаблон: {html.escape(tpl_id)}\n" + f"Причина: {html.escape(err_msg)}" + + (f" ({html.escape(err_code)})" if err_code else "") + + "\n\n" + "Существующая конфигурация не была изменена. " + "Попробуйте другой шаблон или запустите gotelegram из консоли." + ) + 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) # ============================================================================ diff --git a/install.sh b/install.sh index 0bab9c0..a9c5d10 100755 --- a/install.sh +++ b/install.sh @@ -1136,11 +1136,257 @@ menu_language() { esac } +# ══════════════════════════════════════════════════════════════════════════════ +# Non-interactive action dispatcher (bot / CI / scripting interface) +# ══════════════════════════════════════════════════════════════════════════════ +# Usage examples: +# gotelegram --action=change-template --template=th_ariclaw --json +# gotelegram --action=change-lite-domain --domain=google.com --json +# +# Rules for action handlers: +# - Only JSON may be written to stdout (the caller parses it). +# - All human-oriented logging must go to stderr (log_* already do that). +# - Exit code 0 on success, non-zero on failure (caller still parses JSON). +# ══════════════════════════════════════════════════════════════════════════════ + +bot_emit_json() { + # bot_emit_json [key=value ...] + local status="$1"; shift + local message="$1"; shift + local extra="" kv k v + for kv in "$@"; do + k="${kv%%=*}" + v="${kv#*=}" + # escape backslashes and double quotes in value + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + extra="${extra},\"${k}\":\"${v}\"" + done + # escape message + local msg_esc="${message//\\/\\\\}" + msg_esc="${msg_esc//\"/\\\"}" + printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra" +} + +# Update a single key in config.json without rewriting the whole file +bot_update_config_field() { + local key="$1" + local value="$2" + if [ ! -f "$GOTELEGRAM_CONFIG" ]; then + return 1 + fi + local tmp + tmp=$(mktemp) || return 1 + if jq --arg k "$key" --arg v "$value" \ + '.[$k] = $v | .updated_at = (now | todate)' \ + "$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then + mv "$tmp" "$GOTELEGRAM_CONFIG" + chmod 600 "$GOTELEGRAM_CONFIG" + return 0 + fi + rm -f "$tmp" + return 1 +} + +# ── Action: change-template (pro mode only) ────────────────────────────────── +bot_action_change_template() { + local tpl_id="$1" + local json_out="${2:-0}" + + if [ -z "$tpl_id" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "template id is required" "code=missing_arg" + log_error "change-template: --template is required" + return 2 + fi + + # Must be in pro mode + local mode + mode=$(config_get mode 2>/dev/null || echo "") + if [ "$mode" != "pro" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "change-template requires pro mode (current: ${mode:-none})" "code=wrong_mode" + log_error "change-template: current mode is '${mode:-none}', requires 'pro'" + return 3 + fi + + # Validate template id exists in catalog + if ! get_template_info "$tpl_id" >/dev/null 2>&1; then + [ "$json_out" = "1" ] && bot_emit_json "error" "unknown template: $tpl_id" "code=unknown_template" + log_error "change-template: template not found in catalog: $tpl_id" + return 4 + fi + + # Make sure git (and other deps) are present. download_template uses git + # clone under the hood — on a minimal host (bootstrap-only install) git may + # not be installed yet, and the clone would fail silently. + ensure_deps >&2 + + log_info "change-template: downloading $tpl_id..." + local template_dir + template_dir=$(download_template "$tpl_id") + if [ $? -ne 0 ] || [ -z "$template_dir" ] || [ ! -d "$template_dir" ] || [ ! -f "$template_dir/index.html" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "download failed for $tpl_id" "code=download_failed" + log_error "change-template: download_template failed for $tpl_id" + return 5 + fi + + log_info "change-template: deploying to nginx..." + if ! deploy_template_to_nginx "$template_dir" >&2; then + [ "$json_out" = "1" ] && bot_emit_json "error" "deploy failed" "code=deploy_failed" + return 6 + fi + + # Reload nginx (no full restart needed for static files — but be safe) + systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null + + # Update config.json template_id field + bot_update_config_field "template_id" "$tpl_id" || \ + log_warning "change-template: could not update config.json template_id" + + local domain + domain=$(config_get domain 2>/dev/null || echo "") + log_success "change-template: $tpl_id deployed" + + if [ "$json_out" = "1" ]; then + bot_emit_json "success" "template changed to $tpl_id" \ + "template=$tpl_id" "domain=$domain" "mode=pro" + fi + return 0 +} + +# ── Action: change-lite-domain ─────────────────────────────────────────────── +# Regenerates telemt TOML with a new fake-TLS mask domain. Lite mode only. +bot_action_change_lite_domain() { + local new_domain="$1" + local json_out="${2:-0}" + + if [ -z "$new_domain" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "domain is required" "code=missing_arg" + log_error "change-lite-domain: --domain is required" + return 2 + fi + + if ! validate_domain "$new_domain" 2>/dev/null; then + [ "$json_out" = "1" ] && bot_emit_json "error" "invalid domain: $new_domain" "code=invalid_domain" + log_error "change-lite-domain: invalid domain: $new_domain" + return 3 + fi + + local mode + mode=$(config_get mode 2>/dev/null || echo "") + if [ "$mode" != "lite" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "change-lite-domain requires lite mode (current: ${mode:-none})" "code=wrong_mode" + log_error "change-lite-domain: current mode is '${mode:-none}', requires 'lite'" + return 4 + fi + + local secret port + secret=$(get_config_value secret 2>/dev/null || echo "") + port=$(get_config_value port 2>/dev/null || echo "443") + + if [ -z "$secret" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "no secret in config" "code=no_secret" + log_error "change-lite-domain: no secret in config.json" + return 5 + fi + + log_info "change-lite-domain: regenerating telemt TOML..." + generate_telemt_toml "$secret" "$port" "lite" "$new_domain" "443" >&2 || { + [ "$json_out" = "1" ] && bot_emit_json "error" "config generation failed" "code=gen_failed" + return 6 + } + + validate_telemt_config >&2 || { + [ "$json_out" = "1" ] && bot_emit_json "error" "config validation failed" "code=validate_failed" + return 7 + } + + restart_telemt >&2 || { + [ "$json_out" = "1" ] && bot_emit_json "error" "telemt restart failed" "code=restart_failed" + return 8 + } + + # Update both domain and mask_host fields in config.json + bot_update_config_field "mask_host" "$new_domain" || \ + log_warning "change-lite-domain: could not update mask_host" + bot_update_config_field "domain" "$new_domain" || \ + log_warning "change-lite-domain: could not update domain" + + log_success "change-lite-domain: switched to $new_domain" + + if [ "$json_out" = "1" ]; then + bot_emit_json "success" "lite mask domain changed to $new_domain" \ + "domain=$new_domain" "mode=lite" "port=$port" + fi + return 0 +} + +# Main dispatcher — called from main() when --action=X is present +bot_action_dispatch() { + local action="" tpl_id="" domain="" json_out=0 arg + for arg in "$@"; do + case "$arg" in + --action=*) action="${arg#--action=}" ;; + --template=*) tpl_id="${arg#--template=}" ;; + --domain=*) domain="${arg#--domain=}" ;; + --json) json_out=1 ;; + esac + done + + case "$action" in + change-template) + bot_action_change_template "$tpl_id" "$json_out" + return $? + ;; + change-lite-domain) + bot_action_change_lite_domain "$domain" "$json_out" + return $? + ;; + "") + log_error "no --action specified" + return 64 + ;; + *) + [ "$json_out" = "1" ] && bot_emit_json "error" "unknown action: $action" "code=unknown_action" + log_error "unknown action: $action" + return 64 + ;; + esac +} + # ── Точка входа / Entry point ─────────────────────────────────────────────── main() { + # Non-interactive action mode: if --action=X is in args, dispatch and exit. + # Must run BEFORE interactive banner/menus so the bot gets clean JSON. + local a has_action=0 + for a in "$@"; do + case "$a" in --action=*) has_action=1; break ;; esac + done + if [ "$has_action" = "1" ]; then + check_root + init_dirs + # Для bot-экшенов тоже нужны зависимости (git для change-template), но + # без шумного apt-get update если всё уже на месте. + if ! check_deps_present; then + ensure_deps >&2 || exit 1 + fi + bot_action_dispatch "$@" + exit $? + fi + check_root init_dirs + # Первый запуск: если критические зависимости отсутствуют — ставим их ДО + # того как пользователь дойдёт до меню. На последующих запусках это просто + # дёшево проверяет command -v по всем командам и ничего не делает. + if ! check_deps_present; then + log_step "Первый запуск: проверяю зависимости..." + ensure_deps || { + log_error "Не удалось установить зависимости. См. сообщения выше." + exit 1 + } + fi + # First-run language picker (before banner so banner appears in chosen lang) first_run_language_picker diff --git a/lib/common.sh b/lib/common.sh old mode 100644 new mode 100755 index a24e884..4366676 --- a/lib/common.sh +++ b/lib/common.sh @@ -3,7 +3,7 @@ # Colors, logging, spinner, system helpers, v1 compat, i18n-aware # ── Version ─────────────────────────────────────────────────────────────────── -GOTELEGRAM_VERSION="2.4.1" +GOTELEGRAM_VERSION="2.4.2" GOTELEGRAM_NAME="GoTelegram" # ── Пути ────────────────────────────────────────────────────────────────────── @@ -247,25 +247,149 @@ install_pkg() { esac } +# ── Зависимости GoTelegram ────────────────────────────────────────────────── +# Полный список внешних команд, которые скрипт использует. Для каждой команды +# указан пакет на apt и dnf/yum (имена различаются: например dig = dnsutils на +# Debian, bind-utils на RHEL). +# +# КРИТИЧЕСКИЕ (без них скрипт просто не работает): +# jq — парсинг config.json, templates_catalog.json +# curl — скачивание telemt и проверки HTTPS +# openssl — генерация секретов, шифрование бекапов, SSL проверка +# git — клонирование шаблонов через download_template +# xxd — hex-encode домена для fake-TLS секрета (ee-prefix) +# tar — распаковка telemt архива и бекапы +# dig — DNS-проверка домена в Pro-режиме +# +# ЖЕЛАТЕЛЬНЫЕ (есть fallback, но с ними лучше): +# qrencode — QR-коды для прокси-ссылок +# bc — красивое форматирование чисел в статистике +# +# Pro-режим доустанавливает nginx/certbot через install_nginx/install_certbot +# (они большие и нужны только если пользователь выбрал Pro). + +# Маппинг команды -> (apt_pkg, dnf_pkg). apt_pkg_for_cmd +apt_pkg_for_cmd() { + case "$1" in + dig) echo "dnsutils" ;; + xxd) echo "xxd" ;; # Ubuntu 22+: отдельный пакет, fallback ниже + nslookup) echo "dnsutils" ;; + host) echo "dnsutils" ;; + ss) echo "iproute2" ;; + netstat) echo "net-tools" ;; + *) echo "$1" ;; # команда == имя пакета + esac +} + +dnf_pkg_for_cmd() { + case "$1" in + dig|nslookup|host) echo "bind-utils" ;; + xxd) echo "vim-common" ;; + ss) echo "iproute" ;; + netstat) echo "net-tools" ;; + *) echo "$1" ;; + esac +} + ensure_deps() { - local missing=() - for cmd in curl jq openssl git qrencode; do - if ! command -v "$cmd" &>/dev/null; then - missing+=("$cmd") - fi + # Критические зависимости — без них скрипт не работает + local critical=(curl jq openssl git xxd tar dig) + # Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли + local optional=(qrencode bc) + + local missing_critical=() missing_optional=() cmd + for cmd in "${critical[@]}"; do + command -v "$cmd" &>/dev/null || missing_critical+=("$cmd") done - if [ ${#missing[@]} -gt 0 ]; then - if type tf &>/dev/null; then - log_step "$(tf deps_installing "${missing[*]}")" - else - log_step "Installing dependencies: ${missing[*]}" - fi - case "$(get_pkg_manager)" in - apt) apt-get update -qq && apt-get install -y -qq "${missing[@]}" ;; - dnf) dnf install -y -q "${missing[@]}" ;; - yum) yum install -y -q "${missing[@]}" ;; + for cmd in "${optional[@]}"; do + command -v "$cmd" &>/dev/null || missing_optional+=("$cmd") + done + + local all_missing=("${missing_critical[@]}" "${missing_optional[@]}") + [ ${#all_missing[@]} -eq 0 ] && return 0 + + # Собираем список пакетов для выбранного менеджера + local pkg_mgr pkg pkgs=() + pkg_mgr=$(get_pkg_manager) + + for cmd in "${all_missing[@]}"; do + case "$pkg_mgr" in + apt) pkg=$(apt_pkg_for_cmd "$cmd") ;; + dnf|yum) pkg=$(dnf_pkg_for_cmd "$cmd") ;; + *) pkg="$cmd" ;; esac + pkgs+=("$pkg") + done + + # Убираем дубликаты (например dig+nslookup оба = dnsutils) + local uniq_pkgs=() + for pkg in "${pkgs[@]}"; do + local found=0 p + for p in "${uniq_pkgs[@]}"; do + [ "$p" = "$pkg" ] && { found=1; break; } + done + [ "$found" = "0" ] && uniq_pkgs+=("$pkg") + done + + if type tf &>/dev/null; then + log_step "$(tf deps_installing "${all_missing[*]}")" + else + log_step "Installing dependencies: ${all_missing[*]} (packages: ${uniq_pkgs[*]})" fi + + case "$pkg_mgr" in + apt) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq 2>/dev/null + apt-get install -y -qq "${uniq_pkgs[@]}" 2>/dev/null + ;; + dnf) dnf install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;; + yum) yum install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;; + *) + log_error "Unknown package manager — install manually: ${uniq_pkgs[*]}" + return 1 + ;; + esac + + # Фолбэки для xxd: на некоторых системах нужен vim-common вместо xxd + if ! command -v xxd &>/dev/null && [ "$pkg_mgr" = "apt" ]; then + apt-get install -y -qq vim-common 2>/dev/null + fi + + # Повторная проверка критических команд + local still_missing=() + for cmd in "${critical[@]}"; do + command -v "$cmd" &>/dev/null || still_missing+=("$cmd") + done + + if [ ${#still_missing[@]} -gt 0 ]; then + log_error "Critical dependencies still missing: ${still_missing[*]}" + log_error "Install manually and re-run gotelegram" + return 1 + fi + + # Опциональные — только предупреждение + local still_missing_opt=() + for cmd in "${optional[@]}"; do + command -v "$cmd" &>/dev/null || still_missing_opt+=("$cmd") + done + if [ ${#still_missing_opt[@]} -gt 0 ]; then + log_warning "Optional deps missing (features degraded): ${still_missing_opt[*]}" + fi + + log_success "Dependencies ready" + return 0 +} + +# Быстрая проверка — только смотрит что критические установлены, ничего не ставит. +# Возвращает 0 если всё ок, 1 если что-то отсутствует. Используется на старте +# main() чтобы не дёргать apt-get update при каждом запуске меню. +check_deps_present() { + local cmd + for cmd in curl jq openssl git xxd tar dig; do + command -v "$cmd" &>/dev/null || return 1 + done + return 0 } check_port() {