diff --git a/app/bot.py b/app/bot.py
index ee2eda72..997b6ecb 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -43,6 +43,7 @@ from app.handlers.admin import (
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
+ privacy_policy as admin_privacy_policy,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -149,6 +150,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
+ admin_privacy_policy.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
diff --git a/app/database/crud/privacy_policy.py b/app/database/crud/privacy_policy.py
new file mode 100644
index 00000000..627a0e5e
--- /dev/null
+++ b/app/database/crud/privacy_policy.py
@@ -0,0 +1,79 @@
+import logging
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.models import PrivacyPolicy
+
+logger = logging.getLogger(__name__)
+
+
+async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
+ result = await db.execute(
+ select(PrivacyPolicy).where(PrivacyPolicy.language == language)
+ )
+ return result.scalar_one_or_none()
+
+
+async def upsert_privacy_policy(
+ db: AsyncSession,
+ language: str,
+ content: str,
+ *,
+ enable_if_new: bool = True,
+) -> PrivacyPolicy:
+ policy = await get_privacy_policy(db, language)
+
+ if policy:
+ policy.content = content or ""
+ policy.updated_at = datetime.utcnow()
+ else:
+ policy = PrivacyPolicy(
+ language=language,
+ content=content or "",
+ is_enabled=True if enable_if_new else False,
+ )
+ db.add(policy)
+
+ await db.commit()
+ await db.refresh(policy)
+
+ logger.info(
+ "✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
+ language,
+ policy.id,
+ )
+
+ return policy
+
+
+async def set_privacy_policy_enabled(
+ db: AsyncSession,
+ language: str,
+ enabled: bool,
+) -> PrivacyPolicy:
+ policy = await get_privacy_policy(db, language)
+
+ if policy:
+ policy.is_enabled = bool(enabled)
+ policy.updated_at = datetime.utcnow()
+ else:
+ policy = PrivacyPolicy(
+ language=language,
+ content="",
+ is_enabled=bool(enabled),
+ )
+ db.add(policy)
+
+ await db.commit()
+ await db.refresh(policy)
+
+ logger.info(
+ "✅ Статус политики конфиденциальности для языка %s обновлен: %s",
+ language,
+ "enabled" if policy.is_enabled else "disabled",
+ )
+
+ return policy
diff --git a/app/database/models.py b/app/database/models.py
index 1d970966..f48f2f47 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -744,9 +744,9 @@ class Squad(Base):
class ServiceRule(Base):
__tablename__ = "service_rules"
-
+
id = Column(Integer, primary_key=True, index=True)
-
+
order = Column(Integer, default=0)
title = Column(String(255), nullable=False)
@@ -760,6 +760,17 @@ class ServiceRule(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+class PrivacyPolicy(Base):
+ __tablename__ = "privacy_policies"
+
+ id = Column(Integer, primary_key=True, index=True)
+ language = Column(String(10), nullable=False, unique=True)
+ content = Column(Text, nullable=False)
+ is_enabled = Column(Boolean, default=True, nullable=False)
+ created_at = Column(DateTime, default=func.now())
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+
+
class SystemSetting(Base):
__tablename__ = "system_settings"
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index 77ee3c55..a29a748f 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -2500,6 +2500,59 @@ async def create_web_api_tokens_table() -> bool:
return False
+async def create_privacy_policies_table() -> bool:
+ table_exists = await check_table_exists("privacy_policies")
+ if table_exists:
+ logger.info("ℹ️ Таблица privacy_policies уже существует")
+ return True
+
+ try:
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ if db_type == "sqlite":
+ create_sql = """
+ CREATE TABLE privacy_policies (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ language VARCHAR(10) NOT NULL UNIQUE,
+ content TEXT NOT NULL,
+ is_enabled BOOLEAN NOT NULL DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ """
+ elif db_type == "postgresql":
+ create_sql = """
+ CREATE TABLE privacy_policies (
+ id SERIAL PRIMARY KEY,
+ language VARCHAR(10) NOT NULL UNIQUE,
+ content TEXT NOT NULL,
+ is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ );
+ """
+ else:
+ create_sql = """
+ CREATE TABLE privacy_policies (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ language VARCHAR(10) NOT NULL UNIQUE,
+ content TEXT NOT NULL,
+ is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB;
+ """
+
+ await conn.execute(text(create_sql))
+ logger.info("✅ Таблица privacy_policies создана")
+ return True
+
+ except Exception as error:
+ logger.error(f"❌ Ошибка создания таблицы privacy_policies: {error}")
+ return False
+
+
async def ensure_default_web_api_token() -> bool:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
@@ -2582,6 +2635,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
+ logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
+ privacy_policies_ready = await create_privacy_policies_table()
+ if privacy_policies_ready:
+ logger.info("✅ Таблица privacy_policies готова")
+ else:
+ logger.warning("⚠️ Проблемы с таблицей privacy_policies")
+
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
default_token_ready = await ensure_default_web_api_token()
if default_token_ready:
@@ -2868,6 +2928,7 @@ async def check_migration_status():
"subscription_conversions_table": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
+ "privacy_policies_table": False,
"users_promo_group_column": False,
"promo_groups_period_discounts_column": False,
"promo_groups_auto_assign_column": False,
@@ -2892,6 +2953,7 @@ async def check_migration_status():
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
status["user_messages_table"] = await check_table_exists('user_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
+ status["privacy_policies_table"] = await check_table_exists('privacy_policies')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
@@ -2941,6 +3003,7 @@ async def check_migration_status():
"cryptobot_table": "Таблица CryptoBot payments",
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
+ "privacy_policies_table": "Таблица политик конфиденциальности",
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
diff --git a/app/handlers/admin/privacy_policy.py b/app/handlers/admin/privacy_policy.py
new file mode 100644
index 00000000..5f6a8b9f
--- /dev/null
+++ b/app/handlers/admin/privacy_policy.py
@@ -0,0 +1,512 @@
+import html
+import logging
+from datetime import datetime
+
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.models import User
+from app.localization.texts import get_texts
+from app.services.privacy_policy_service import PrivacyPolicyService
+from app.states import AdminStates
+from app.utils.decorators import admin_required, error_handler
+from app.utils.validators import validate_html_tags, get_html_help_text
+
+logger = logging.getLogger(__name__)
+
+
+def _format_timestamp(value: datetime | None) -> str:
+ if not value:
+ return ""
+ try:
+ return value.strftime("%d.%m.%Y %H:%M")
+ except Exception:
+ return ""
+
+
+async def _build_overview(
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ policy = await PrivacyPolicyService.get_policy(
+ db,
+ db_user.language,
+ fallback=False,
+ )
+
+ normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
+ has_content = bool(policy and policy.content and policy.content.strip())
+
+ description = texts.t(
+ "ADMIN_PRIVACY_POLICY_DESCRIPTION",
+ "Политика конфиденциальности отображается в разделе «Инфо».",
+ )
+
+ status_text = texts.t(
+ "ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
+ "⚠️ Показ политики выключен или текст отсутствует.",
+ )
+ if policy and policy.is_enabled and has_content:
+ status_text = texts.t(
+ "ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
+ "✅ Политика активна и показывается пользователям.",
+ )
+ elif policy and policy.is_enabled:
+ status_text = texts.t(
+ "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
+ "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
+ )
+
+ updated_at = _format_timestamp(getattr(policy, "updated_at", None))
+ updated_block = ""
+ if updated_at:
+ updated_block = texts.t(
+ "ADMIN_PRIVACY_POLICY_UPDATED_AT",
+ "Последнее обновление: {timestamp}",
+ ).format(timestamp=updated_at)
+
+ preview_block = texts.t(
+ "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
+ "Текст ещё не задан.",
+ )
+ if has_content:
+ preview_title = texts.t(
+ "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
+ "Превью текста:",
+ )
+ preview_raw = policy.content.strip()
+ preview_trimmed = preview_raw[:400]
+ if len(preview_raw) > 400:
+ preview_trimmed += "..."
+ preview_block = (
+ f"{preview_title}\n"
+ f"{html.escape(preview_trimmed)}"
+ )
+
+ language_block = texts.t(
+ "ADMIN_PRIVACY_POLICY_LANGUAGE",
+ "Язык: {lang}",
+ ).format(lang=normalized_language)
+
+ header = texts.t(
+ "ADMIN_PRIVACY_POLICY_HEADER",
+ "🛡️ Политика конфиденциальности",
+ )
+ actions_prompt = texts.t(
+ "ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
+ "Выберите действие:",
+ )
+
+ message_parts = [
+ header,
+ description,
+ language_block,
+ status_text,
+ ]
+
+ if updated_block:
+ message_parts.append(updated_block)
+
+ message_parts.append(preview_block)
+ message_parts.append(actions_prompt)
+
+ overview_text = "\n\n".join(part for part in message_parts if part)
+
+ buttons: list[list[types.InlineKeyboardButton]] = []
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
+ "✏️ Изменить текст",
+ ),
+ callback_data="admin_privacy_policy_edit",
+ )
+ ])
+
+ if has_content:
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
+ "👀 Просмотреть текущий текст",
+ ),
+ callback_data="admin_privacy_policy_view",
+ )
+ ])
+
+ toggle_text = texts.t(
+ "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
+ "✅ Включить показ",
+ )
+ if policy and policy.is_enabled:
+ toggle_text = texts.t(
+ "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
+ "🚫 Отключить показ",
+ )
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=toggle_text,
+ callback_data="admin_privacy_policy_toggle",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_HTML_HELP",
+ "ℹ️ HTML помощь",
+ ),
+ callback_data="admin_privacy_policy_help",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="admin_submenu_settings",
+ )
+ ])
+
+ return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
+
+
+@admin_required
+@error_handler
+async def show_privacy_policy_management(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ overview_text, markup, _ = await _build_overview(db_user, db)
+
+ await callback.message.edit_text(
+ overview_text,
+ reply_markup=markup,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def toggle_privacy_policy(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
+ logger.info(
+ "Админ %s переключил показ политики конфиденциальности: %s",
+ db_user.telegram_id,
+ "enabled" if updated_policy.is_enabled else "disabled",
+ )
+ status_message = (
+ texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
+ if updated_policy.is_enabled
+ else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
+ )
+
+ overview_text, markup, _ = await _build_overview(db_user, db)
+ await callback.message.edit_text(
+ overview_text,
+ reply_markup=markup,
+ )
+ await callback.answer(status_message)
+
+
+@admin_required
+@error_handler
+async def start_edit_privacy_policy(
+ callback: types.CallbackQuery,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ policy = await PrivacyPolicyService.get_policy(
+ db,
+ db_user.language,
+ fallback=False,
+ )
+
+ current_preview = ""
+ if policy and policy.content:
+ preview = policy.content.strip()[:400]
+ if len(policy.content.strip()) > 400:
+ preview += "..."
+ current_preview = (
+ texts.t(
+ "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
+ "Текущий текст (превью):",
+ )
+ + f"\n{html.escape(preview)}\n\n"
+ )
+
+ prompt = texts.t(
+ "ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
+ "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
+ )
+
+ hint = texts.t(
+ "ADMIN_PRIVACY_POLICY_EDIT_HINT",
+ "Используйте /html_help для справки по тегам.",
+ )
+
+ message_text = (
+ f"📝 {texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}\n\n"
+ f"{current_preview}{prompt}\n\n{hint}"
+ )
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_HTML_HELP",
+ "ℹ️ HTML помощь",
+ ),
+ callback_data="admin_privacy_policy_help",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
+ callback_data="admin_privacy_policy_cancel",
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(message_text, reply_markup=keyboard)
+ await state.set_state(AdminStates.editing_privacy_policy)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def cancel_edit_privacy_policy(
+ callback: types.CallbackQuery,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ await state.clear()
+ overview_text, markup, _ = await _build_overview(db_user, db)
+ await callback.message.edit_text(
+ overview_text,
+ reply_markup=markup,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def process_privacy_policy_edit(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ new_text = message.text or ""
+
+ if len(new_text) > 4000:
+ await message.answer(
+ texts.t(
+ "ADMIN_PRIVACY_POLICY_TOO_LONG",
+ "❌ Текст политики слишком длинный. Максимум 4000 символов.",
+ )
+ )
+ return
+
+ is_valid, error_message = validate_html_tags(new_text)
+ if not is_valid:
+ await message.answer(
+ texts.t(
+ "ADMIN_PRIVACY_POLICY_HTML_ERROR",
+ "❌ Ошибка в HTML: {error}",
+ ).format(error=error_message)
+ )
+ return
+
+ await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
+ logger.info(
+ "Админ %s обновил текст политики конфиденциальности (%d символов)",
+ db_user.telegram_id,
+ len(new_text),
+ )
+ await state.clear()
+
+ success_text = texts.t(
+ "ADMIN_PRIVACY_POLICY_SAVED",
+ "✅ Политика конфиденциальности обновлена.",
+ )
+
+ reply_markup = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_BACK_BUTTON",
+ "⬅️ К настройкам политики",
+ ),
+ callback_data="admin_privacy_policy",
+ )
+ ]
+ ]
+ )
+
+ await message.answer(success_text, reply_markup=reply_markup)
+
+
+@admin_required
+@error_handler
+async def view_privacy_policy(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ policy = await PrivacyPolicyService.get_policy(
+ db,
+ db_user.language,
+ fallback=False,
+ )
+
+ if not policy or not policy.content or not policy.content.strip():
+ await callback.answer(
+ texts.t(
+ "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
+ "Текст политики пока не задан.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ content = policy.content.strip()
+ truncated = False
+ max_length = 3800
+ if len(content) > max_length:
+ content = content[: max_length - 3] + "..."
+ truncated = True
+
+ header = texts.t(
+ "ADMIN_PRIVACY_POLICY_VIEW_TITLE",
+ "👀 Текущий текст политики",
+ )
+
+ note = ""
+ if truncated:
+ note = texts.t(
+ "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
+ "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
+ )
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_BACK_BUTTON",
+ "⬅️ К настройкам политики",
+ ),
+ callback_data="admin_privacy_policy",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
+ "✏️ Изменить текст",
+ ),
+ callback_data="admin_privacy_policy_edit",
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ f"{header}\n\n{content}{note}",
+ reply_markup=keyboard,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_privacy_policy_html_help(
+ callback: types.CallbackQuery,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ help_text = get_html_help_text()
+
+ current_state = await state.get_state()
+
+ buttons: list[list[types.InlineKeyboardButton]] = []
+
+ if current_state == AdminStates.editing_privacy_policy.state:
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
+ "⬅️ Назад к редактированию",
+ ),
+ callback_data="admin_privacy_policy_edit",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PRIVACY_POLICY_BACK_BUTTON",
+ "⬅️ К настройкам политики",
+ ),
+ callback_data="admin_privacy_policy",
+ )
+ ])
+
+ await callback.message.edit_text(
+ help_text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
+ )
+ await callback.answer()
+
+
+def register_handlers(dp: Dispatcher) -> None:
+ dp.callback_query.register(
+ show_privacy_policy_management,
+ F.data == "admin_privacy_policy",
+ )
+ dp.callback_query.register(
+ toggle_privacy_policy,
+ F.data == "admin_privacy_policy_toggle",
+ )
+ dp.callback_query.register(
+ start_edit_privacy_policy,
+ F.data == "admin_privacy_policy_edit",
+ )
+ dp.callback_query.register(
+ cancel_edit_privacy_policy,
+ F.data == "admin_privacy_policy_cancel",
+ )
+ dp.callback_query.register(
+ view_privacy_policy,
+ F.data == "admin_privacy_policy_view",
+ )
+ dp.callback_query.register(
+ show_privacy_policy_html_help,
+ F.data == "admin_privacy_policy_help",
+ )
+
+ dp.message.register(
+ process_privacy_policy_edit,
+ AdminStates.editing_privacy_policy,
+ )
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index 3bb757f0..fab18d5b 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -25,6 +25,7 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
+from app.services.privacy_policy_service import PrivacyPolicyService
logger = logging.getLogger(__name__)
@@ -110,15 +111,127 @@ async def show_info_menu(
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
caption = f"{header}\n\n{prompt}" if prompt else header
+ privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
+
await edit_or_answer_photo(
callback=callback,
caption=caption,
- keyboard=get_info_menu_keyboard(language=db_user.language),
+ keyboard=get_info_menu_keyboard(
+ language=db_user.language,
+ show_privacy_policy=privacy_enabled,
+ ),
parse_mode="HTML",
)
await callback.answer()
+async def show_privacy_policy(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ raw_page = 1
+ if callback.data and ":" in callback.data:
+ try:
+ raw_page = int(callback.data.split(":", 1)[1])
+ except ValueError:
+ raw_page = 1
+
+ if raw_page < 1:
+ raw_page = 1
+
+ policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
+
+ if not policy:
+ await callback.answer(
+ texts.t(
+ "PRIVACY_POLICY_NOT_AVAILABLE",
+ "Политика конфиденциальности временно недоступна.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ pages = PrivacyPolicyService.split_content_into_pages(policy.content)
+
+ if not pages:
+ await callback.answer(
+ texts.t(
+ "PRIVACY_POLICY_EMPTY_ALERT",
+ "Политика конфиденциальности ещё не заполнена.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ total_pages = len(pages)
+ current_page = raw_page if raw_page <= total_pages else total_pages
+
+ header = texts.t(
+ "PRIVACY_POLICY_HEADER",
+ "🛡️ Политика конфиденциальности",
+ )
+ body = pages[current_page - 1]
+
+ footer_template = texts.t(
+ "PRIVACY_POLICY_PAGE_INFO",
+ "Страница {current} из {total}",
+ )
+ footer = ""
+ if total_pages > 1 and footer_template:
+ try:
+ footer = footer_template.format(current=current_page, total=total_pages)
+ except Exception:
+ footer = f"{current_page}/{total_pages}"
+
+ message_text = header
+ if body:
+ message_text += f"\n\n{body}"
+ if footer:
+ message_text += f"\n\n{footer}"
+
+ keyboard_rows: list[list[types.InlineKeyboardButton]] = []
+
+ if total_pages > 1:
+ nav_row: list[types.InlineKeyboardButton] = []
+ if current_page > 1:
+ nav_row.append(
+ types.InlineKeyboardButton(
+ text=texts.t("PAGINATION_PREV", "⬅️"),
+ callback_data=f"menu_privacy_policy:{current_page - 1}",
+ )
+ )
+
+ nav_row.append(
+ types.InlineKeyboardButton(
+ text=f"{current_page}/{total_pages}",
+ callback_data="noop",
+ )
+ )
+
+ if current_page < total_pages:
+ nav_row.append(
+ types.InlineKeyboardButton(
+ text=texts.t("PAGINATION_NEXT", "➡️"),
+ callback_data=f"menu_privacy_policy:{current_page + 1}",
+ )
+ )
+
+ keyboard_rows.append(nav_row)
+
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
+ )
+
+ await callback.message.edit_text(
+ message_text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
+ )
+ await callback.answer()
+
+
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
@@ -388,6 +501,16 @@ def register_handlers(dp: Dispatcher):
F.data == "menu_info",
)
+ dp.callback_query.register(
+ show_privacy_policy,
+ F.data == "menu_privacy_policy",
+ )
+
+ dp.callback_query.register(
+ show_privacy_policy,
+ F.data.startswith("menu_privacy_policy:"),
+ )
+
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 2f5200cf..72a66fbd 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -178,6 +178,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
callback_data="maintenance_panel"
)
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."),
+ callback_data="admin_privacy_policy",
+ )
+ ],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 4d55d594..5bfa995b 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -280,12 +280,25 @@ def get_main_menu_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
-def get_info_menu_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
+def get_info_menu_keyboard(
+ language: str = DEFAULT_LANGUAGE,
+ show_privacy_policy: bool = False,
+) -> InlineKeyboardMarkup:
texts = get_texts(language)
- buttons: List[List[InlineKeyboardButton]] = [
- [InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")]
- ]
+ buttons: List[List[InlineKeyboardButton]] = []
+
+ if show_privacy_policy:
+ buttons.append([
+ InlineKeyboardButton(
+ text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
+ callback_data="menu_privacy_policy",
+ )
+ ])
+
+ buttons.append([
+ InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
+ ])
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index be98b240..f66baaec 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -234,6 +234,11 @@
"MENU_INFO": "ℹ️ Инфо",
"MENU_INFO_HEADER": "ℹ️ Инфо",
"MENU_INFO_PROMPT": "Выберите раздел:",
+ "MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
+ "PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности",
+ "PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
+ "PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
+ "PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -683,6 +688,37 @@
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
+ "ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
+ "ADMIN_PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности",
+ "ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
+ "ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: {lang}",
+ "ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
+ "ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
+ "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
+ "ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
+ "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "Превью текста:",
+ "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
+ "ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
+ "ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
+ "ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
+ "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
+ "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
+ "ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML помощь",
+ "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
+ "ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
+ "ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
+ "ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
+ "ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
+ "ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
+ "ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
+ "ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
+ "ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
+ "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
+ "ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 Текущий текст политики",
+ "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
+ "ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
+ "ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
+ "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
diff --git a/app/services/privacy_policy_service.py b/app/services/privacy_policy_service.py
new file mode 100644
index 00000000..bba935fb
--- /dev/null
+++ b/app/services/privacy_policy_service.py
@@ -0,0 +1,178 @@
+import logging
+from typing import Optional, List
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.privacy_policy import (
+ get_privacy_policy,
+ set_privacy_policy_enabled,
+ upsert_privacy_policy,
+)
+from app.database.models import PrivacyPolicy
+
+logger = logging.getLogger(__name__)
+
+
+class PrivacyPolicyService:
+ """Utility helpers around privacy policy storage and presentation."""
+
+ MAX_PAGE_LENGTH = 3500
+
+ @staticmethod
+ def _normalize_language(language: str) -> str:
+ base_language = language or settings.DEFAULT_LANGUAGE or "ru"
+ return base_language.split("-")[0].lower()
+
+ @staticmethod
+ def normalize_language(language: str) -> str:
+ return PrivacyPolicyService._normalize_language(language)
+
+ @classmethod
+ async def get_policy(
+ cls,
+ db: AsyncSession,
+ language: str,
+ *,
+ fallback: bool = False,
+ ) -> Optional[PrivacyPolicy]:
+ lang = cls._normalize_language(language)
+ policy = await get_privacy_policy(db, lang)
+
+ if policy or not fallback:
+ return policy
+
+ default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
+ if lang != default_lang:
+ return await get_privacy_policy(db, default_lang)
+
+ return policy
+
+ @classmethod
+ async def get_active_policy(
+ cls,
+ db: AsyncSession,
+ language: str,
+ ) -> Optional[PrivacyPolicy]:
+ lang = cls._normalize_language(language)
+ policy = await get_privacy_policy(db, lang)
+
+ if policy and policy.is_enabled and policy.content.strip():
+ return policy
+
+ default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
+ if lang != default_lang:
+ fallback_policy = await get_privacy_policy(db, default_lang)
+ if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
+ return fallback_policy
+
+ return None
+
+ @classmethod
+ async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
+ policy = await cls.get_active_policy(db, language)
+ return policy is not None
+
+ @classmethod
+ async def save_policy(
+ cls,
+ db: AsyncSession,
+ language: str,
+ content: str,
+ ) -> PrivacyPolicy:
+ lang = cls._normalize_language(language)
+ enable_if_new = True
+ policy = await upsert_privacy_policy(
+ db,
+ lang,
+ content,
+ enable_if_new=enable_if_new,
+ )
+ logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
+ return policy
+
+ @classmethod
+ async def set_enabled(
+ cls,
+ db: AsyncSession,
+ language: str,
+ enabled: bool,
+ ) -> PrivacyPolicy:
+ lang = cls._normalize_language(language)
+ return await set_privacy_policy_enabled(db, lang, enabled)
+
+ @classmethod
+ async def toggle_enabled(
+ cls,
+ db: AsyncSession,
+ language: str,
+ ) -> PrivacyPolicy:
+ lang = cls._normalize_language(language)
+ policy = await get_privacy_policy(db, lang)
+
+ if policy:
+ new_status = not policy.is_enabled
+ else:
+ new_status = True
+
+ return await set_privacy_policy_enabled(db, lang, new_status)
+
+ @staticmethod
+ def split_content_into_pages(
+ content: str,
+ *,
+ max_length: int = None,
+ ) -> List[str]:
+ if not content:
+ return []
+
+ normalized = content.replace("\r\n", "\n").strip()
+ if not normalized:
+ return []
+
+ max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
+
+ if len(normalized) <= max_len:
+ return [normalized]
+
+ paragraphs = [
+ paragraph.strip()
+ for paragraph in normalized.split("\n\n")
+ if paragraph.strip()
+ ]
+
+ pages: List[str] = []
+ current = ""
+
+ def flush_current() -> None:
+ nonlocal current
+ if current:
+ pages.append(current.strip())
+ current = ""
+
+ for paragraph in paragraphs:
+ candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
+ if len(candidate) <= max_len:
+ current = candidate
+ continue
+
+ flush_current()
+
+ if len(paragraph) <= max_len:
+ current = paragraph
+ continue
+
+ start_index = 0
+ while start_index < len(paragraph):
+ chunk = paragraph[start_index:start_index + max_len]
+ pages.append(chunk.strip())
+ start_index += max_len
+
+ current = ""
+
+ flush_current()
+
+ if not pages:
+ return [normalized[:max_len]]
+
+ return pages
diff --git a/app/states.py b/app/states.py
index ab6a0eab..1e42df99 100644
--- a/app/states.py
+++ b/app/states.py
@@ -87,6 +87,7 @@ class AdminStates(StatesGroup):
editing_user_traffic = State()
editing_rules_page = State()
+ editing_privacy_policy = State()
editing_notification_value = State()
confirming_sync = State()
diff --git a/locales/en.json b/locales/en.json
index 6d008896..54cf63dd 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -333,6 +333,11 @@
"MENU_INFO": "ℹ️ Info",
"MENU_INFO_HEADER": "ℹ️ Info",
"MENU_INFO_PROMPT": "Choose a section:",
+ "MENU_PRIVACY_POLICY": "🛡️ Privacy policy",
+ "PRIVACY_POLICY_HEADER": "🛡️ Privacy policy",
+ "PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.",
+ "PRIVACY_POLICY_EMPTY_ALERT": "The privacy policy text has not been provided yet.",
+ "PRIVACY_POLICY_PAGE_INFO": "Page {current} of {total}",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
@@ -753,6 +758,37 @@
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
+ "ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
+ "ADMIN_PRIVACY_POLICY_HEADER": "🛡️ Privacy policy",
+ "ADMIN_PRIVACY_POLICY_DESCRIPTION": "The privacy policy is shown in the Info section.",
+ "ADMIN_PRIVACY_POLICY_LANGUAGE": "Language: {lang}",
+ "ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Policy display is disabled or empty.",
+ "ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Policy is active and visible to users.",
+ "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Policy is enabled but the text is empty — users won't see it.",
+ "ADMIN_PRIVACY_POLICY_UPDATED_AT": "Last updated: {timestamp}",
+ "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "Text preview:",
+ "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "No text provided yet.",
+ "ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Choose an action:",
+ "ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Edit text",
+ "ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 View current text",
+ "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Enable display",
+ "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Disable display",
+ "ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML help",
+ "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Current text (preview):",
+ "ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Send the new privacy policy text. HTML markup is allowed.",
+ "ADMIN_PRIVACY_POLICY_EDIT_HINT": "Use /html_help for supported tags.",
+ "ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Privacy policy editing",
+ "ADMIN_PRIVACY_POLICY_CANCEL": "❌ Cancel",
+ "ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ The policy text is too long. Maximum 4000 characters.",
+ "ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ HTML error: {error}",
+ "ADMIN_PRIVACY_POLICY_SAVED": "✅ Privacy policy updated.",
+ "ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Back to policy settings",
+ "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "The privacy policy text is not set yet.",
+ "ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 Current policy text",
+ "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ The text is shortened for display. Users will see the full version in the menu.",
+ "ADMIN_PRIVACY_POLICY_ENABLED": "✅ Policy enabled",
+ "ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Policy disabled",
+ "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Back to editing",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",