commit a50fa92542c75ccf8c2d6b58f267468b3cebac66 Author: root Date: Mon Sep 15 00:47:01 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7faf40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddad5bf --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Connex +A Telegram bot that eases sharing VPN configurations with users + +## Key Features +- Admin panel for managing users and configurations. +- Ability to add and edit tutorials for users to help them understand how to use client applications. + +## Tech Stack +- **Programming language:** Python +- **Database:** SQLite + +## Dependencies +- **aiogram** — for interacting with the Telegram API. +- **aiosqlite3** — for database operations. +- **asyncio** diff --git a/handlers/admin_handlers.py b/handlers/admin_handlers.py new file mode 100644 index 0000000..3767a9b --- /dev/null +++ b/handlers/admin_handlers.py @@ -0,0 +1,373 @@ +# handlers/admin_handlers.py + +import sqlite3 +import asyncio +from aiogram import Router, F, types, Bot +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.enums import ContentType +from aiogram.exceptions import TelegramAPIError + +from keyboards import ( + get_main_keyboard_by_role, get_users_keyboard, user_management_keyboard, + get_users_for_configs_keyboard, get_user_configs_management_keyboard, + get_tutorials_admin_keyboard, get_skip_media_keyboard, get_confirm_send_keyboard +) +from localization import get_text + +router = Router() + +# --- Helper function to get admin's language --- +def get_admin_lang(user_id: int) -> str: + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT language_code FROM users WHERE telegram_id = ?", (user_id,)) + result = cursor.fetchone() + conn.close() + return result[0] if result else 'en' + +# --- FSM States --- +class AdminStates(StatesGroup): + add_user_id = State() + add_config_type = State() + add_config_data = State() + add_tutorial_title = State() + add_tutorial_text = State() + add_tutorial_media = State() + mass_send_message = State() + mass_send_confirm = State() + +# --- Main Menu Handler --- +@router.callback_query(F.data == "admin_menu") +async def process_admin_menu(callback: types.CallbackQuery, state: FSMContext): + await state.clear() # Clear any active state + lang = get_admin_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('welcome_admin', lang), + reply_markup=get_main_keyboard_by_role(is_admin=True, lang=lang) + ) + await callback.answer() + +# --- User Management Section --- + +@router.callback_query(F.data.startswith("admin_users_page_")) +async def process_users_list(callback: types.CallbackQuery): + page = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('users_list', lang), + reply_markup=get_users_keyboard(page, lang) + ) + await callback.answer() + +@router.callback_query(F.data.startswith("manage_user_")) +async def process_manage_user(callback: types.CallbackQuery): + user_id = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT username FROM users WHERE telegram_id = ?", (user_id,)) + user = cursor.fetchone() + conn.close() + username = user[0] if user and user[0] else "N/A" + + text = get_text('manage_user_title', lang).format(user_id=user_id, username=username) + await callback.message.edit_text( + text, + reply_markup=user_management_keyboard(user_id, lang), + parse_mode="Markdown" + ) + await callback.answer() + +@router.callback_query(F.data.startswith("delete_user_")) +async def process_delete_user(callback: types.CallbackQuery): + user_id = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE telegram_id = ?", (user_id,)) + # Configs will be deleted automatically due to "ON DELETE CASCADE" in the new DB schema + conn.commit() + conn.close() + + await callback.answer(get_text('user_deleted_ok', lang)) + await callback.message.edit_text( + get_text('users_list', lang), + reply_markup=get_users_keyboard(0, lang) + ) + +@router.callback_query(F.data == "add_user") +async def process_add_user_start(callback: types.CallbackQuery, state: FSMContext): + lang = get_admin_lang(callback.from_user.id) + await callback.message.edit_text(get_text('ask_for_user_id', lang)) + await state.set_state(AdminStates.add_user_id) + await callback.answer() + +@router.message(AdminStates.add_user_id) +async def process_add_user_id(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + try: + user_id = int(message.text) + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT telegram_id FROM users WHERE telegram_id = ?", (user_id,)) + if cursor.fetchone(): + await message.answer(get_text('user_already_exists', lang)) + else: + # Add user with default language 'en' + cursor.execute("INSERT INTO users (telegram_id, language_code) VALUES (?, ?)", (user_id, 'en')) + conn.commit() + await message.answer(get_text('user_added_ok', lang)) + conn.close() + + await state.clear() + await message.answer( + get_text('users_list', lang), + reply_markup=get_users_keyboard(0, lang) + ) + except (ValueError, TypeError): + await message.answer(get_text('invalid_id_format', lang)) + +# --- Config Management Section --- +# (This section is also refactored to use the lang parameter) + +@router.callback_query(F.data.startswith("admin_configs_page_")) +async def process_config_users_list(callback: types.CallbackQuery): + page = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('choose_user_for_config', lang), + reply_markup=get_users_for_configs_keyboard(page, lang) + ) + await callback.answer() + +@router.callback_query(F.data.startswith("user_configs_manage_")) +async def process_user_configs_manage(callback: types.CallbackQuery): + user_id = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('user_configs_title', lang), + reply_markup=get_user_configs_management_keyboard(user_id, lang) + ) + await callback.answer() + +@router.callback_query(F.data.startswith("delete_config:")) +async def process_delete_config(callback: types.CallbackQuery): + _, config_id, user_id = callback.data.split(":") + config_id, user_id = int(config_id), int(user_id) + lang = get_admin_lang(callback.from_user.id) + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("DELETE FROM configs WHERE id = ?", (config_id,)) + conn.commit() + conn.close() + + await callback.answer(get_text('config_deleted_ok', lang)) + await callback.message.edit_text( + get_text('user_configs_title', lang), + reply_markup=get_user_configs_management_keyboard(user_id, lang) + ) + +# ... (Add Config FSM flow refactored for localization) +@router.callback_query(F.data.startswith("add_config_")) +async def process_add_config_start(callback: types.CallbackQuery, state: FSMContext): + user_id = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + await state.update_data(current_user_id=user_id) + await state.set_state(AdminStates.add_config_type) + await callback.message.edit_text(get_text('add_config_step1', lang)) + await callback.answer() + +@router.message(AdminStates.add_config_type) +async def process_add_config_type(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + await state.update_data(config_type=message.text) + await state.set_state(AdminStates.add_config_data) + await message.answer(get_text('add_config_step2', lang)) + +@router.message(AdminStates.add_config_data, F.content_type.in_({ContentType.TEXT, ContentType.DOCUMENT})) +async def process_add_config_data(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + data = await state.get_data() + user_id = data['current_user_id'] + + # ... (logic for handling file/text remains the same) + if message.document: + file_id = message.document.file_id + file_name = message.document.file_name + config_type = f"file:{file_name}" + config_data = file_id + else: + config_type = data['config_type'] + config_data = message.text + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("INSERT INTO configs (user_id, config_type, config_data) VALUES (?, ?, ?)", + (user_id, config_type, config_data)) + conn.commit() + conn.close() + + await state.clear() + await message.answer(get_text('config_added_ok', lang)) + await message.answer( + get_text('user_configs_title', lang), + reply_markup=get_user_configs_management_keyboard(user_id, lang) + ) + +# --- Tutorial Management Section --- +@router.callback_query(F.data == "admin_tutorials_menu") +async def process_tutorials_menu(callback: types.CallbackQuery): + lang = get_admin_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('tutorials_menu_title', lang), + reply_markup=get_tutorials_admin_keyboard(lang) + ) + await callback.answer() +# ... (rest of the tutorial management refactored similarly) + +@router.callback_query(F.data.startswith("delete_tutorial_")) +async def process_delete_tutorial(callback: types.CallbackQuery): + tutorial_id = int(callback.data.split("_")[-1]) + lang = get_admin_lang(callback.from_user.id) + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("DELETE FROM tutorials WHERE id = ?", (tutorial_id,)) + conn.commit() + conn.close() + await callback.answer(get_text('tutorial_deleted_ok', lang)) + await callback.message.edit_text( + get_text('tutorials_menu_title', lang), + reply_markup=get_tutorials_admin_keyboard(lang) + ) + +@router.callback_query(F.data == "add_tutorial") +async def process_add_tutorial_start(callback: types.CallbackQuery, state: FSMContext): + lang = get_admin_lang(callback.from_user.id) + await state.set_state(AdminStates.add_tutorial_title) + await callback.message.edit_text(get_text('add_tutorial_step1', lang)) + await callback.answer() + +@router.message(AdminStates.add_tutorial_title) +async def process_add_tutorial_title(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + await state.update_data(title=message.text) + await state.set_state(AdminStates.add_tutorial_text) + await message.answer(get_text('add_tutorial_step2', lang)) + +@router.message(AdminStates.add_tutorial_text) +async def process_add_tutorial_text(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + await state.update_data(text=message.text) + await state.set_state(AdminStates.add_tutorial_media) + await message.answer( + get_text('add_tutorial_step3', lang), + reply_markup=get_skip_media_keyboard(lang) + ) + +@router.callback_query(F.data == "skip_media", AdminStates.add_tutorial_media) +async def process_skip_media(callback: types.CallbackQuery, state: FSMContext): + lang = get_admin_lang(callback.from_user.id) + data = await state.get_data() + # ... (DB logic is the same) + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("INSERT INTO tutorials (title, content_text) VALUES (?, ?)", (data['title'], data['text'])) + conn.commit() + conn.close() + await state.clear() + await callback.message.edit_text(get_text('tutorial_added_ok_no_media', lang)) + await callback.message.answer( + get_text('tutorials_menu_title', lang), + reply_markup=get_tutorials_admin_keyboard(lang) + ) + await callback.answer() + +@router.message(AdminStates.add_tutorial_media, F.content_type.in_({ContentType.PHOTO, ContentType.VIDEO})) +async def process_add_tutorial_media(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + # ... (DB and file_id logic is the same) + file_id = "" + if message.photo: + file_id = message.photo[-1].file_id + elif message.video: + file_id = message.video.file_id + data = await state.get_data() + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("INSERT INTO tutorials (title, content_text, file_id) VALUES (?, ?, ?)", + (data['title'], data['text'], file_id)) + conn.commit() + conn.close() + await state.clear() + await message.answer(get_text('tutorial_added_ok_with_media', lang)) + await message.answer( + get_text('tutorials_menu_title', lang), + reply_markup=get_tutorials_admin_keyboard(lang) + ) + +# --- Mass Messaging Section --- +@router.callback_query(F.data == "mass_send_start") +async def process_mass_send_start(callback: types.CallbackQuery, state: FSMContext): + lang = get_admin_lang(callback.from_user.id) + await state.set_state(AdminStates.mass_send_message) + await callback.message.edit_text(get_text('mass_send_ask_message', lang)) + await callback.answer() + +@router.message(AdminStates.mass_send_message) +async def process_mass_send_message(message: types.Message, state: FSMContext): + lang = get_admin_lang(message.from_user.id) + await state.update_data(message_to_send=message) + await state.set_state(AdminStates.mass_send_confirm) + await message.answer( + get_text('mass_send_confirm_message', lang), + reply_markup=get_confirm_send_keyboard(lang) + ) + +@router.callback_query(F.data == "send_cancelled", AdminStates.mass_send_confirm) +async def process_send_cancelled(callback: types.CallbackQuery, state: FSMContext): + lang = get_admin_lang(callback.from_user.id) + await state.clear() + await callback.message.edit_text( + get_text('mass_send_cancelled', lang), + reply_markup=get_main_keyboard_by_role(is_admin=True, lang=lang) + ) + await callback.answer() + +@router.callback_query(F.data == "send_confirmed", AdminStates.mass_send_confirm) +async def process_send_confirmed(callback: types.CallbackQuery, state: FSMContext, bot: Bot): + lang = get_admin_lang(callback.from_user.id) + data = await state.get_data() + message_to_send = data['message_to_send'] + await state.clear() + + await callback.message.edit_text(get_text('mass_send_started', lang), reply_markup=None) + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT telegram_id FROM users WHERE is_admin = 0") + user_ids = cursor.fetchall() + conn.close() + + success_count = 0 + fail_count = 0 + for (user_id,) in user_ids: + try: + await message_to_send.copy_to(chat_id=user_id) + success_count += 1 + except TelegramAPIError as e: + print(f"Failed to send to {user_id}: {e}") + fail_count += 1 + await asyncio.sleep(0.1) + + result_text = get_text('mass_send_finished', lang).format( + success_count=success_count, + fail_count=fail_count + ) + await callback.message.answer( + result_text, + reply_markup=get_main_keyboard_by_role(is_admin=True, lang=lang) + ) diff --git a/handlers/settings_handlers.py b/handlers/settings_handlers.py new file mode 100644 index 0000000..b8fbed7 --- /dev/null +++ b/handlers/settings_handlers.py @@ -0,0 +1,48 @@ +# handlers/settings_handlers.py + +import sqlite3 +from aiogram import Router, F, types + +from keyboards import get_language_choice_keyboard, get_main_keyboard_by_role +from localization import get_text + +router = Router() + +@router.callback_query(F.data == "settings") +async def process_settings(callback: types.CallbackQuery): + # Получаем язык пользователя для корректного отображения меню + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT language_code FROM users WHERE telegram_id = ?", (callback.from_user.id,)) + lang = cursor.fetchone()[0] + conn.close() + + await callback.message.edit_text( + get_text('choose_language', lang), + reply_markup=get_language_choice_keyboard() + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("set_lang_")) +async def process_set_language(callback: types.CallbackQuery): + lang_code = callback.data.split("_")[-1] + user_id = callback.from_user.id + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("UPDATE users SET language_code = ? WHERE telegram_id = ?", (lang_code, user_id)) + cursor.execute("SELECT is_admin FROM users WHERE telegram_id = ?", (user_id,)) + is_admin = cursor.fetchone()[0] + conn.commit() + conn.close() + + await callback.message.edit_text(get_text('language_changed', lang_code)) + + # Показываем главное меню на новом языке + main_menu_text = get_text('welcome_admin' if is_admin else 'welcome', lang_code) + await callback.message.answer( + main_menu_text, + reply_markup=get_main_keyboard_by_role(is_admin, lang_code) + ) + await callback.answer() diff --git a/handlers/user_handlers.py b/handlers/user_handlers.py new file mode 100644 index 0000000..88a08ef --- /dev/null +++ b/handlers/user_handlers.py @@ -0,0 +1,98 @@ +# handlers/user_handlers.py + +import sqlite3 +from aiogram import Router, F, types, Bot +from keyboards import get_main_keyboard_by_role, get_tutorials_user_keyboard +from localization import get_text + +router = Router() + +# Вспомогательная функция для получения языка пользователя +def get_user_lang(user_id: int) -> str: + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT language_code FROM users WHERE telegram_id = ?", (user_id,)) + result = cursor.fetchone() + conn.close() + return result[0] if result else 'en' + +# Новый обработчик для кнопки "Назад в меню" из раздела помощи +@router.callback_query(F.data == "user_main_menu") +async def process_back_to_main_menu(callback: types.CallbackQuery): + lang = get_user_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('welcome', lang), + reply_markup=get_main_keyboard_by_role(is_admin=False, lang=lang) + ) + await callback.answer() + +@router.callback_query(F.data == "user_configs") +async def process_user_configs(callback: types.CallbackQuery, bot: Bot): + user_id = callback.from_user.id + lang = get_user_lang(user_id) + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT config_type, config_data FROM configs WHERE user_id = ?", (user_id,)) + user_configs = cursor.fetchall() + conn.close() + + if not user_configs: + await callback.message.answer(get_text('no_configs_yet', lang)) + else: + await callback.message.answer(get_text('your_configs', lang)) + for config_type, config_data in user_configs: + if config_type.startswith("file:"): + file_id = config_data + caption = f"{get_text('config_type', lang)}: {config_type.split(':', 1)[1]}" + await bot.send_document(chat_id=user_id, document=file_id, caption=caption) + else: + await callback.message.answer(f"{get_text('config_type', lang)}: `{config_type}`\n\n`{config_data}`", parse_mode="Markdown") + + await callback.message.answer( + get_text('next_action', lang), + reply_markup=get_main_keyboard_by_role(is_admin=False, lang=lang) + ) + await callback.answer() + +@router.callback_query(F.data == "user_help") +async def process_user_help(callback: types.CallbackQuery): + lang = get_user_lang(callback.from_user.id) + await callback.message.edit_text( + get_text('choose_tutorial', lang), + reply_markup=get_tutorials_user_keyboard(lang) + ) + await callback.answer() + +@router.callback_query(F.data.startswith("view_tutorial_")) +async def process_view_tutorial(callback: types.CallbackQuery, bot: Bot): + user_id = callback.from_user.id + lang = get_user_lang(user_id) + tutorial_id = int(callback.data.split("_")[-1]) + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT content_text, file_id FROM tutorials WHERE id = ?", (tutorial_id,)) + tutorial = cursor.fetchone() + conn.close() + + if tutorial: + content_text, file_id = tutorial + # Сначала удаляем предыдущее сообщение с кнопками + await callback.message.delete() + if file_id: + try: + await bot.send_photo(chat_id=user_id, photo=file_id, caption=content_text) + except: + await bot.send_video(chat_id=user_id, video=file_id, caption=content_text) + else: + await callback.message.answer(content_text) + + await callback.message.answer( + get_text('next_action', lang), + reply_markup=get_main_keyboard_by_role(is_admin=False, lang=lang) + ) + else: + await callback.answer(get_text('error_not_found', lang), show_alert=True) + + await callback.answer() diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..d87e43a --- /dev/null +++ b/keyboards.py @@ -0,0 +1,181 @@ +# keyboards.py + +import sqlite3 +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from localization import get_text + +USERS_PER_PAGE = 5 + +# --- ОСНОВНЫЕ КЛАВИАТУРЫ --- + +def get_main_keyboard_by_role(is_admin: bool, lang: str) -> InlineKeyboardMarkup: + """Возвращает админскую или пользовательскую клавиатуру на нужном языке.""" + buttons = [] + if is_admin: + buttons = [ + [InlineKeyboardButton(text=get_text('manage_users_btn', lang), callback_data="admin_users_page_0")], + [InlineKeyboardButton(text=get_text('manage_configs_btn', lang), callback_data="admin_configs_page_0")], + [InlineKeyboardButton(text=get_text('manage_tutorials_btn', lang), callback_data="admin_tutorials_menu")], + [InlineKeyboardButton(text=get_text('mass_send_btn', lang), callback_data="mass_send_start")] + ] + else: + buttons = [ + [InlineKeyboardButton(text=get_text('my_configs_btn', lang), callback_data="user_configs")], + [InlineKeyboardButton(text=get_text('help_btn', lang), callback_data="user_help")] + ] + # Добавляем кнопку настроек для всех + buttons.append([InlineKeyboardButton(text=get_text('settings', lang), callback_data="settings")]) + return InlineKeyboardMarkup(inline_keyboard=buttons) + +def get_back_to_menu_keyboard(lang: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=get_text('back_to_menu', lang), callback_data="admin_menu")] + ]) + +# --- НАСТРОЙКИ ЯЗЫКА --- + +def get_language_choice_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="English 🇬🇧", callback_data="set_lang_en")], + [InlineKeyboardButton(text="Русский 🇷🇺", callback_data="set_lang_ru")] + ]) + +# --- УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ --- + +def get_users_keyboard(page: int, lang: str) -> InlineKeyboardMarkup: + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 0") + total_users = cursor.fetchone()[0] + offset = page * USERS_PER_PAGE + cursor.execute("SELECT telegram_id, username FROM users WHERE is_admin = 0 LIMIT ? OFFSET ?", (USERS_PER_PAGE, offset)) + users = cursor.fetchall() + conn.close() + + keyboard = [] + for user_id, username in users: + button_text = f"@{username}" if username else f"ID: {user_id}" + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=f"manage_user_{user_id}")]) + + nav_buttons = [] + if page > 0: + nav_buttons.append(InlineKeyboardButton(text=get_text('prev_btn', lang), callback_data=f"admin_users_page_{page-1}")) + if (page + 1) * USERS_PER_PAGE < total_users: + nav_buttons.append(InlineKeyboardButton(text=get_text('next_btn', lang), callback_data=f"admin_users_page_{page+1}")) + if nav_buttons: + keyboard.append(nav_buttons) + + keyboard.append([InlineKeyboardButton(text=get_text('add_user_btn', lang), callback_data="add_user")]) + keyboard.append([InlineKeyboardButton(text=get_text('back_to_menu', lang), callback_data="admin_menu")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + +def user_management_keyboard(user_id: int, lang: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=get_text('delete_user_btn', lang), callback_data=f"delete_user_{user_id}")], + [InlineKeyboardButton(text=get_text('back_to_list_btn', lang), callback_data="admin_users_page_0")] + ]) + +# --- УПРАВЛЕНИЕ КОНФИГУРАЦИЯМИ --- + +def get_users_for_configs_keyboard(page: int, lang: str) -> InlineKeyboardMarkup: + # Эта функция дублирует get_users_keyboard, но с другими callback_data + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 0") + total_users = cursor.fetchone()[0] + offset = page * USERS_PER_PAGE + cursor.execute("SELECT telegram_id, username FROM users WHERE is_admin = 0 LIMIT ? OFFSET ?", (USERS_PER_PAGE, offset)) + users = cursor.fetchall() + conn.close() + + keyboard = [] + for user_id, username in users: + button_text = f"@{username}" if username else f"ID: {user_id}" + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=f"user_configs_manage_{user_id}")]) + + nav_buttons = [] + if page > 0: + nav_buttons.append(InlineKeyboardButton(text=get_text('prev_btn', lang), callback_data=f"admin_configs_page_{page-1}")) + if (page + 1) * USERS_PER_PAGE < total_users: + nav_buttons.append(InlineKeyboardButton(text=get_text('next_btn', lang), callback_data=f"admin_configs_page_{page+1}")) + if nav_buttons: + keyboard.append(nav_buttons) + + keyboard.append([InlineKeyboardButton(text=get_text('back_to_menu', lang), callback_data="admin_menu")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + +def get_user_configs_management_keyboard(user_id: int, lang: str) -> InlineKeyboardMarkup: + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT id, config_type, config_data FROM configs WHERE user_id = ?", (user_id,)) + configs = cursor.fetchall() + conn.close() + + keyboard = [] + for config_id, config_type, config_data in configs: + if config_type.startswith("file:"): + display_text = f"{get_text('file_prefix', lang)}: {config_type.split(':', 1)[1]}" + else: + short_data = config_data[:20] + '...' if len(config_data) > 20 else config_data + display_text = f"{config_type}: {short_data}" + + button_text = f"{get_text('delete_config_prefix', lang)} {display_text}" + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=f"delete_config:{config_id}:{user_id}")]) + + keyboard.append([InlineKeyboardButton(text=get_text('add_config_btn', lang), callback_data=f"add_config_{user_id}")]) + keyboard.append([InlineKeyboardButton(text=get_text('back_to_users_list_btn', lang), callback_data="admin_configs_page_0")]) + keyboard.append([InlineKeyboardButton(text=get_text('main_menu', lang), callback_data="admin_menu")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + +# --- УПРАВЛЕНИЕ ТУТОРИАЛАМИ --- + +def get_tutorials_admin_keyboard(lang: str) -> InlineKeyboardMarkup: + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT id, title FROM tutorials") + tutorials = cursor.fetchall() + conn.close() + + keyboard = [] + for tutorial_id, title in tutorials: + keyboard.append([InlineKeyboardButton(text=f"{get_text('delete_tutorial_prefix', lang)} {title}", callback_data=f"delete_tutorial_{tutorial_id}")]) + + keyboard.append([InlineKeyboardButton(text=get_text('add_tutorial_btn', lang), callback_data="add_tutorial")]) + keyboard.append([InlineKeyboardButton(text=get_text('back_to_menu', lang), callback_data="admin_menu")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + +def get_tutorials_user_keyboard(lang: str) -> InlineKeyboardMarkup: + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + cursor.execute("SELECT id, title FROM tutorials") + tutorials = cursor.fetchall() + conn.close() + + keyboard = [] + if not tutorials: + keyboard.append([InlineKeyboardButton(text=get_text('no_tutorials_yet', lang), callback_data="no_op")]) + else: + for tutorial_id, title in tutorials: + keyboard.append([InlineKeyboardButton(text=f"📖 {title}", callback_data=f"view_tutorial_{tutorial_id}")]) + + # Добавляем кнопку "Назад", которая вернет пользователя в главное меню + # Для этого нам нужна информация о его роли + # Проще всего будет, если обработчик этой кнопки сам вернет главное меню + # Поэтому здесь просто ставим callback_data, который поймает user_handler + keyboard.append([InlineKeyboardButton(text=get_text('back_to_menu', 'ru' if lang=='ru' else 'en'), callback_data="user_main_menu")]) + + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + +# --- РАССЫЛКА И ПРОЧЕЕ --- + +def get_confirm_send_keyboard(lang: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=get_text('send_btn', lang), callback_data="send_confirmed")], + [InlineKeyboardButton(text=get_text('cancel_btn', lang), callback_data="send_cancelled")] + ]) + +def get_skip_media_keyboard(lang: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=get_text('skip_btn', lang), callback_data="skip_media")] + ]) diff --git a/localization.py b/localization.py new file mode 100644 index 0000000..e41962b --- /dev/null +++ b/localization.py @@ -0,0 +1,150 @@ +# localization.py + +locales = { + 'en': { + # General + 'welcome': "👋 Welcome!\n\nUse the menu to navigate.", + 'welcome_admin': "👋 Welcome, Administrator!\n\nChoose an action:", + 'back_to_menu': "⬅️ Back to Menu", + 'main_menu': "🏠 Main Menu", + 'error_not_found': "Not found.", + 'under_development': "This section is under development.", + # Settings + 'settings': "⚙️ Settings", + 'choose_language': "Please choose your language:", + 'language_changed': "Language has been changed to English 🇬🇧", + # User Menu + 'my_configs_btn': "🔑 My Configurations", + 'help_btn': "❓ Help", + 'no_configs_yet': "You have no available configurations yet.", + 'your_configs': "Your configurations:", + 'next_action': "Choose the next action:", + 'config_type': "Type", + # Help/Tutorials + 'choose_tutorial': "Choose a tutorial you are interested in:", + 'no_tutorials_yet': "No tutorials yet", + # Admin Menu + 'manage_users_btn': "👤 User Management", + 'manage_configs_btn': "🔑 Configuration Management", + 'manage_tutorials_btn': "📚 Tutorial Management", + 'mass_send_btn': "📢 Mass Messaging", + # User Management + 'users_list': "👥 User List:", + 'prev_btn': "⬅️ Prev.", + 'next_btn': "➡️ Next.", + 'add_user_btn': "➕ Add User", + 'manage_user_title': "Managing user:\nID: `{user_id}`\nUsername: @{username}", + 'delete_user_btn': "🗑️ Delete User", + 'back_to_list_btn': "⬅️ Back to List", + 'user_deleted_ok': "User and their configurations have been deleted.", + 'ask_for_user_id': "Enter the Telegram ID of the new user.\n\nTo cancel, press /start.", + 'user_already_exists': "This user already exists in the database.", + 'user_added_ok': "✅ User successfully added!", + 'invalid_id_format': "❗️Invalid format. Please enter a numerical Telegram ID.", + # Config Management + 'choose_user_for_config': "Choose a user to manage their configurations:", + 'user_configs_title': "User configurations:", + 'delete_config_prefix': "🗑️", + 'file_prefix': "File", + 'add_config_btn': "➕ Add Config", + 'back_to_users_list_btn': "⬅️ Back to User List", + 'config_deleted_ok': "Configuration deleted.", + 'add_config_step1': "Step 1/2: Enter the configuration type (e.g., VLESS, WireGuard, SS).\nIf this is a file, the type will be used as its description.", + 'add_config_step2': "Step 2/2: Now, send the configuration data (link, text, or **file**).", + 'config_added_ok': "✅ Configuration successfully added!", + # Tutorial Management + 'tutorials_menu_title': "Tutorial management menu:", + 'delete_tutorial_prefix': "🗑️", + 'add_tutorial_btn': "➕ Add Tutorial", + 'tutorial_deleted_ok': "Tutorial deleted.", + 'add_tutorial_step1': "Step 1/3: Enter the tutorial title:", + 'add_tutorial_step2': "Step 2/3: Enter the main text of the tutorial:", + 'add_tutorial_step3': "Step 3/3: Now attach a photo or video. If no media is required, press 'Skip'.", + 'skip_btn': "Skip Step ➡️", + 'tutorial_added_ok_no_media': "✅ Tutorial without media added successfully.", + 'tutorial_added_ok_with_media': "✅ Tutorial with media added successfully.", + # Mass Messaging + 'mass_send_ask_message': "Enter the message for mass sending to all users. It will be copied and sent.", + 'mass_send_confirm_message': "This message will be sent to all users. Confirm sending:", + 'send_btn': "✅ Send", + 'cancel_btn': "❌ Cancel", + 'mass_send_cancelled': "Mass messaging cancelled.", + 'mass_send_started': "⏳ Starting mass messaging...", + 'mass_send_finished': "✅ Mass messaging finished!\n\nSuccessfully sent: {success_count}\nFailed to deliver: {fail_count}", + }, + 'ru': { + # General + 'welcome': "👋 Добро пожаловать!\n\nИспользуйте меню для навигации.", + 'welcome_admin': "👋 Добро пожаловать, Администратор!\n\nВыберите действие:", + 'back_to_menu': "⬅️ Назад в меню", + 'main_menu': "🏠 Главное меню", + 'error_not_found': "Не найдено.", + 'under_development': "Этот раздел находится в разработке.", + # Settings + 'settings': "⚙️ Настройки", + 'choose_language': "Пожалуйста, выберите язык:", + 'language_changed': "Язык изменен на Русский 🇷🇺", + # User Menu + 'my_configs_btn': "🔑 Мои конфигурации", + 'help_btn': "❓ Помощь", + 'no_configs_yet': "У вас пока нет доступных конфигураций.", + 'your_configs': "Ваши конфигурации:", + 'next_action': "Выберите следующее действие:", + 'config_type': "Тип", + # Help/Tutorials + 'choose_tutorial': "Выберите интересующий вас туториал:", + 'no_tutorials_yet': "Туториалов пока нет", + # Admin Menu + 'manage_users_btn': "👤 Управление пользователями", + 'manage_configs_btn': "🔑 Управление конфигурациями", + 'manage_tutorials_btn': "📚 Управление туториалами", + 'mass_send_btn': "📢 Сделать рассылку", + # User Management + 'users_list': "👥 Список пользователей:", + 'prev_btn': "⬅️ Пред.", + 'next_btn': "➡️ След.", + 'add_user_btn': "➕ Добавить пользователя", + 'manage_user_title': "Управление пользователем:\nID: `{user_id}`\nUsername: @{username}", + 'delete_user_btn': "🗑️ Удалить пользователя", + 'back_to_list_btn': "⬅️ Назад к списку", + 'user_deleted_ok': "Пользователь и его конфигурации удалены.", + 'ask_for_user_id': "Введите Telegram ID нового пользователя.\n\nЧтобы отменить, нажмите /start.", + 'user_already_exists': "Этот пользователь уже существует в базе.", + 'user_added_ok': "✅ Пользователь успешно добавлен!", + 'invalid_id_format': "❗️Неверный формат. Пожалуйста, введите числовой Telegram ID.", + # Config Management + 'choose_user_for_config': "Выберите пользователя для управления его конфигурациями:", + 'user_configs_title': "Конфигурации пользователя:", + 'delete_config_prefix': "🗑️", + 'file_prefix': "Файл", + 'add_config_btn': "➕ Добавить конфиг", + 'back_to_users_list_btn': "⬅️ Назад к списку пользователей", + 'config_deleted_ok': "Конфигурация удалена.", + 'add_config_step1': "Шаг 1/2: Введите тип конфигурации (например, VLESS, WireGuard, SS).\nЕсли это файл, тип будет использован как его описание.", + 'add_config_step2': "Шаг 2/2: Теперь отправьте данные конфигурации (ссылку, текст или **файл**).", + 'config_added_ok': "✅ Конфигурация успешно добавлена!", + # Tutorial Management + 'tutorials_menu_title': "Меню управления туториалами:", + 'delete_tutorial_prefix': "🗑️", + 'add_tutorial_btn': "➕ Добавить туториал", + 'tutorial_deleted_ok': "Туториал удален.", + 'add_tutorial_step1': "Шаг 1/3: Введите заголовок туториала:", + 'add_tutorial_step2': "Шаг 2/3: Введите основной текст туториала:", + 'add_tutorial_step3': "Шаг 3/3: Теперь прикрепите фото или видео. Если медиа не требуется, нажмите 'Пропустить'.", + 'skip_btn': "Пропустить шаг ➡️", + 'tutorial_added_ok_no_media': "✅ Туториал без медиа успешно добавлен.", + 'tutorial_added_ok_with_media': "✅ Туториал с медиа успешно добавлен.", + # Mass Messaging + 'mass_send_ask_message': "Введите сообщение для рассылки всем пользователям. Оно будет скопировано и отправлено.", + 'mass_send_confirm_message': "Это сообщение будет отправлено всем пользователям. Подтвердите рассылку:", + 'send_btn': "✅ Отправить", + 'cancel_btn': "❌ Отмена", + 'mass_send_cancelled': "Рассылка отменена.", + 'mass_send_started': "⏳ Начинаю рассылку...", + 'mass_send_finished': "✅ Рассылка завершена!\n\nУспешно отправлено: {success_count}\nНе удалось доставить: {fail_count}", + } +} + +def get_text(key: str, lang: str = 'en'): + """Возвращает текст по ключу для заданного языка, с фолбэком на английский.""" + return locales.get(lang, locales['en']).get(key, f"<{key}>") diff --git a/main.py b/main.py new file mode 100644 index 0000000..290eb3a --- /dev/null +++ b/main.py @@ -0,0 +1,128 @@ +# main.py + +import asyncio +import logging +import sqlite3 + +from aiogram import Bot, Dispatcher, types +from aiogram.filters.command import Command + +# --- Import local modules --- +from keyboards import get_main_keyboard_by_role +from handlers import admin_handlers, user_handlers, settings_handlers # Import new settings handler +from localization import get_text + +# --- Settings --- +BOT_TOKEN = "YOUR_BOT_TOKEN" +ADMIN_IDS = [YOUR_TELEGRAM_ID] + +# --- Initialization --- +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher() + +# --- Database Initialization --- +def init_db(): + """Initializes the database and tables.""" + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + + # Enable foreign key support + cursor.execute("PRAGMA foreign_keys = ON") + + # Create users table with a new language_code column + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + telegram_id INTEGER PRIMARY KEY, + username TEXT, + is_admin INTEGER DEFAULT 0, + language_code TEXT DEFAULT 'en' + )''') + + # Create configs table with ON DELETE CASCADE + # This automatically deletes a user's configs when the user is deleted + cursor.execute(''' + CREATE TABLE IF NOT EXISTS configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + config_type TEXT, + config_data TEXT, + FOREIGN KEY (user_id) REFERENCES users (telegram_id) ON DELETE CASCADE + )''') + + # Create tutorials table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tutorials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + content_text TEXT, + file_id TEXT + )''') + + # Add/update admins from the ADMIN_IDS list + for admin_id in ADMIN_IDS: + cursor.execute( + "INSERT INTO users (telegram_id, is_admin, language_code) VALUES (?, 1, 'en') " + "ON CONFLICT(telegram_id) DO UPDATE SET is_admin = 1", + (admin_id,) + ) + + conn.commit() + conn.close() + +# --- Register Routers --- +dp.include_router(admin_handlers.router) +dp.include_router(user_handlers.router) +dp.include_router(settings_handlers.router) # Register the new settings router + +# --- /start Command Handler --- +@dp.message(Command("start")) +async def send_welcome(message: types.Message): + """ + Handles the /start command. + Greets the user, adds them to the DB if they are new, + and shows the appropriate menu in their selected language. + """ + user_id = message.from_user.id + username = message.from_user.username + + conn = sqlite3.connect('bot.db') + cursor = conn.cursor() + + # Check if user exists + cursor.execute("SELECT is_admin, language_code FROM users WHERE telegram_id = ?", (user_id,)) + result = cursor.fetchone() + + if result: + # User exists, get their role and language + is_admin, lang = result + else: + # New user, add to DB with default language 'en' + is_admin = 1 if user_id in ADMIN_IDS else 0 + lang = 'en' + cursor.execute( + "INSERT INTO users (telegram_id, username, is_admin, language_code) VALUES (?, ?, ?, ?)", + (user_id, username, is_admin, lang) + ) + conn.commit() + + conn.close() + + # Determine the welcome text based on role and language + welcome_text_key = 'welcome_admin' if is_admin else 'welcome' + welcome_text = get_text(welcome_text_key, lang) + + # Show the main menu + await message.answer( + welcome_text, + reply_markup=get_main_keyboard_by_role(is_admin, lang) + ) + +# --- Entry Point --- +async def main(): + """Main function to start the bot.""" + logging.basicConfig(level=logging.INFO) + init_db() # Initialize the database on startup + await dp.start_polling(bot) + +if __name__ == "__main__": + asyncio.run(main())