diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index e2411a1..1266670 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -64,15 +64,67 @@ TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" PROMO_STAMP_FILE = "/opt/gotelegram/.promo_bot_last_shown" BOT_TOKEN = os.getenv("BOT_TOKEN") -ALLOWED_IDS_STR = os.getenv("ALLOWED_IDS", "") +ENV_FILE = "/opt/gotelegram-bot/.env" + +# ── Загрузка ALLOWED_IDS ──────────────────────────────────────────────────── +# Поддерживает запятую, пробел, или их комбинацию как разделитель ALLOWED_IDS: set = set() -for _id_str in ALLOWED_IDS_STR.split(","): - _id_str = _id_str.strip() - if _id_str: - try: - ALLOWED_IDS.add(int(_id_str)) - except ValueError: - logging.warning(f"Invalid ALLOWED_IDS entry: {_id_str}") +_WAITING_FOR_ADMIN = False # True если список пуст → ждём первого админа + + +def _load_allowed_ids() -> None: + """Загрузить ALLOWED_IDS из переменной окружения.""" + global ALLOWED_IDS, _WAITING_FOR_ADMIN + raw = os.getenv("ALLOWED_IDS", "") + ALLOWED_IDS = set() + # Разделители: запятая, пробел, или оба + for part in re.split(r'[,\s]+', raw): + part = part.strip() + if part: + try: + ALLOWED_IDS.add(int(part)) + except ValueError: + logging.warning(f"Invalid ALLOWED_IDS entry: {part}") + _WAITING_FOR_ADMIN = len(ALLOWED_IDS) == 0 + + +def _save_allowed_ids() -> None: + """Сохранить ALLOWED_IDS в .env файл и обновить os.environ.""" + global _WAITING_FOR_ADMIN + ids_str = ",".join(str(i) for i in sorted(ALLOWED_IDS)) + os.environ["ALLOWED_IDS"] = ids_str + _WAITING_FOR_ADMIN = len(ALLOWED_IDS) == 0 + + if not os.path.exists(ENV_FILE): + return + + try: + with open(ENV_FILE, "r") as f: + lines = f.readlines() + + found = False + new_lines = [] + for line in lines: + if line.strip().startswith("ALLOWED_IDS="): + if ids_str: + new_lines.append(f"ALLOWED_IDS={ids_str}\n") + # Если пусто — удаляем строку + found = True + else: + new_lines.append(line) + + if not found and ids_str: + new_lines.append(f"ALLOWED_IDS={ids_str}\n") + + with open(ENV_FILE, "w") as f: + f.writelines(new_lines) + + logger.info(f"ALLOWED_IDS updated in .env: {ids_str or '(empty)'}") + except OSError as e: + logger.error(f"Failed to update .env: {e}") + + +_load_allowed_ids() LITE_DOMAINS = [ "google.com", @@ -230,21 +282,41 @@ async def check_old_container() -> Optional[str]: def is_user_allowed(user_id: int) -> bool: - """Check if user ID is in ALLOWED_IDS.""" - if not ALLOWED_IDS: - return True + """Check if user ID is in ALLOWED_IDS. If list is empty — waiting for admin.""" + if _WAITING_FOR_ADMIN: + return False # Никому не даём доступ пока не назначен админ return user_id in ALLOWED_IDS +def add_admin(user_id: int) -> None: + """Добавить администратора и сохранить в .env.""" + ALLOWED_IDS.add(user_id) + _save_allowed_ids() + logger.info(f"Admin added: {user_id}") + + +def remove_admin(user_id: int) -> None: + """Убрать администратора и сохранить в .env.""" + ALLOWED_IDS.discard(user_id) + _save_allowed_ids() + logger.info(f"Admin removed: {user_id}") + + async def require_auth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: """Check authorization and send error if not allowed.""" - if not is_user_allowed(update.effective_user.id): - await update.message.reply_text( - f"Access denied. Your ID: {update.effective_user.id}" - ) - logger.warning( - f"Unauthorized access attempt from user {update.effective_user.id}" - ) + user_id = update.effective_user.id + + # Режим ожидания первого админа — обрабатывается в cmd_start + if _WAITING_FOR_ADMIN: + return False + + if not is_user_allowed(user_id): + if update.message: + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {user_id}", + parse_mode="HTML", + ) + logger.warning(f"Unauthorized access attempt from user {user_id}") return False return True @@ -286,7 +358,10 @@ def get_main_menu() -> InlineKeyboardMarkup: InlineKeyboardButton("🗑️ Remove", callback_data="menu_remove"), ], [ + InlineKeyboardButton("👤 Админы", callback_data="menu_admins"), InlineKeyboardButton("ℹ️ Credits", callback_data="menu_credits"), + ], + [ InlineKeyboardButton("❌ Close", callback_data="close_menu"), ], ] @@ -299,8 +374,37 @@ def get_main_menu() -> InlineKeyboardMarkup: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Start command - show main menu, promo once per day.""" - if not await require_auth(update, context): + """Start command - show main menu, promo once per day. + + Если ALLOWED_IDS пуст — режим авто-регистрации первого админа. + """ + user = update.effective_user + user_id = user.id + + # ── Режим ожидания первого админа ── + if _WAITING_FOR_ADMIN: + name = user.full_name or user.username or str(user_id) + text = ( + f"👋 Привет, {html.escape(name)}!\n\n" + f"Бот ещё не настроен.\n" + f"Ваш Telegram ID: {user_id}\n\n" + f"Назначить вас администратором?" + ) + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ Да", callback_data=f"admin_confirm_{user_id}"), + InlineKeyboardButton("❌ Нет", callback_data="admin_cancel"), + ] + ]) + await update.message.reply_text(text, reply_markup=keyboard, parse_mode="HTML") + return + + # ── Проверка доступа ── + if not is_user_allowed(user_id): + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {user_id}", + parse_mode="HTML", + ) return welcome = ( @@ -327,12 +431,14 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: return help_text = ( - "GoTelegram Bot Commands\n\n" - "/start - Show main menu\n" - "/help - Show this help message\n" - "/status - Quick status check\n" - "/logs - Show recent logs\n\n" - "Use the inline menu for all other operations." + "GoTelegram Bot — Команды\n\n" + "/start — Главное меню\n" + "/help — Эта справка\n" + "/status — Быстрый статус\n" + "/logs — Последние логи\n" + "/addadmin ID — Добавить админа\n" + "/deladmin ID — Удалить админа\n\n" + "Используйте кнопки меню для остальных операций." ) await update.message.reply_text(help_text, parse_mode="HTML") @@ -1322,6 +1428,114 @@ async def cb_ssl_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") +# ============================================================================ +# ADMIN MANAGEMENT +# ============================================================================ + + +async def cb_menu_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Показать список админов и кнопки управления.""" + query = update.callback_query + await query.answer() + + if ALLOWED_IDS: + ids_list = "\n".join(f" • {uid}" for uid in sorted(ALLOWED_IDS)) + text = f"👤 Администраторы\n\n{ids_list}\n" + else: + text = "👤 Администраторы\n\nСписок пуст — доступ для всех\n" + + text += ( + f"\nВсего: {len(ALLOWED_IDS)}\n\n" + "Чтобы добавить — перешлите любое сообщение от нового админа, " + "или отправьте команду:\n" + "/addadmin 123456789\n\n" + "Чтобы удалить:\n" + "/deladmin 123456789" + ) + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("« Назад", callback_data="menu_main")], + ]) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + + +async def cmd_addadmin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/addadmin ID [ID2 ID3 ...] — добавить админа вручную.""" + if not is_user_allowed(update.effective_user.id): + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {update.effective_user.id}", + parse_mode="HTML", + ) + return + + args = context.args or [] + if not args: + await update.message.reply_text( + "Использование: /addadmin ID [ID2 ID3 ...]\n" + "Пример: /addadmin 123456789 987654321", + parse_mode="HTML", + ) + return + + added = [] + errors = [] + for a in args: + a = a.strip().replace(",", "") + if not a: + continue + try: + uid = int(a) + add_admin(uid) + added.append(str(uid)) + except ValueError: + errors.append(a) + + parts = [] + if added: + parts.append(f"✅ Добавлены: {', '.join(added)}") + if errors: + parts.append(f"❌ Ошибки: {', '.join(errors)}") + + await update.message.reply_text("\n".join(parts), parse_mode="HTML") + + +async def cmd_deladmin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/deladmin ID — удалить админа.""" + if not is_user_allowed(update.effective_user.id): + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {update.effective_user.id}", + parse_mode="HTML", + ) + return + + args = context.args or [] + if not args: + await update.message.reply_text( + "Использование: /deladmin ID", + parse_mode="HTML", + ) + return + + removed = [] + for a in args: + a = a.strip().replace(",", "") + try: + uid = int(a) + if uid == update.effective_user.id: + await update.message.reply_text("⚠️ Нельзя удалить себя!") + continue + if uid in ALLOWED_IDS: + remove_admin(uid) + removed.append(str(uid)) + else: + await update.message.reply_text(f"ID {uid} не найден в списке") + except ValueError: + await update.message.reply_text(f"❌ Некорректный ID: {html.escape(a)}") + + if removed: + await update.message.reply_text(f"✅ Удалены: {', '.join(removed)}") + + # ============================================================================ # PROMO & CREDITS # ============================================================================ @@ -1463,9 +1677,43 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> query = update.callback_query data = query.data + # ── Авто-регистрация админа (до проверки доступа!) ── + if data.startswith("admin_confirm_"): + await query.answer() + try: + new_admin_id = int(data.split("_")[-1]) + except (ValueError, IndexError): + await safe_edit_message(query, "❌ Ошибка: некорректный ID") + return + # Безопасность: только тот кто нажал кнопку может стать админом + if update.effective_user.id != new_admin_id: + await query.answer("Эта кнопка не для вас", show_alert=True) + return + # Race condition: если кто-то уже стал админом + if not _WAITING_FOR_ADMIN: + await safe_edit_message(query, "ℹ️ Администратор уже назначен.") + return + add_admin(new_admin_id) + await safe_edit_message( + query, + f"✅ Вы назначены администратором!\n\n" + f"ID: {new_admin_id}\n\n" + f"Нажмите /start чтобы открыть меню.", + parse_mode="HTML", + ) + return + + if data == "admin_cancel": + await query.answer() + await safe_edit_message( + query, + "👋 Ок. Напишите /start когда будете готовы.", + ) + return + # Access control if not is_user_allowed(update.effective_user.id): - await query.answer("Access denied") + await query.answer("Доступ запрещён") return # Main menu @@ -1500,6 +1748,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "menu_website": cb_menu_website, "menu_promo": cb_menu_promo, "menu_credits": cb_menu_credits, + "menu_admins": cb_menu_admins, "menu_remove": cb_menu_remove, "install_mode_lite": cb_install_mode_lite, "install_mode_pro": cb_install_mode_pro, @@ -1560,6 +1809,8 @@ def main() -> None: application.add_handler(CommandHandler("help", cmd_help)) application.add_handler(CommandHandler("status", cmd_status)) application.add_handler(CommandHandler("logs", cmd_logs)) + application.add_handler(CommandHandler("addadmin", cmd_addadmin)) + application.add_handler(CommandHandler("deladmin", cmd_deladmin)) # Callback query handler (buttons) application.add_handler(CallbackQueryHandler(handle_callback)) diff --git a/install.sh b/install.sh index 25f78a4..f86c9f2 100755 --- a/install.sh +++ b/install.sh @@ -799,14 +799,20 @@ bot_install() { [ -z "$token" ] && log_error "Токен не может быть пустым" done - echo -ne " ${WHITE}ID администратора (Enter = доступ для всех):${NC} " - read -r admin_id + echo -ne " ${WHITE}ID администраторов (через пробел/запятую, Enter = авто):${NC} " + read -r admin_ids + # Нормализуем: пробелы и запятые → запятые + admin_ids=$(echo "$admin_ids" | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//') { echo "BOT_TOKEN=$token" - [ -n "$admin_id" ] && echo "ALLOWED_IDS=$admin_id" + [ -n "$admin_ids" ] && echo "ALLOWED_IDS=$admin_ids" } > "$BOT_DIR/.env" + if [ -z "$admin_ids" ]; then + echo -e " ${YELLOW}Авто-режим: первый кто напишет /start станет админом${NC}" + fi + chmod 600 "$BOT_DIR/.env" log_success ".env создан" else @@ -908,9 +914,10 @@ bot_edit_config() { fi ;; 2) - echo -ne " ${WHITE}ALLOWED_IDS (через запятую, пусто = все):${NC} " + echo -ne " ${WHITE}ALLOWED_IDS (через пробел/запятую, пусто = авто):${NC} " read -r new_ids - new_ids=$(echo "$new_ids" | tr -d '[:space:]') + # Нормализуем: пробелы и запятые → запятые, убираем лишнее + new_ids=$(echo "$new_ids" | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//') if grep -q "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null; then if [ -n "$new_ids" ]; then sed -i "s|^ALLOWED_IDS=.*|ALLOWED_IDS=$new_ids|" "$BOT_DIR/.env"