diff --git a/app/bot.py b/app/bot.py
index 997b6ecb..d5815987 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -44,6 +44,7 @@ from app.handlers.admin import (
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
+ public_offer as admin_public_offer,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -151,6 +152,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
admin_privacy_policy.register_handlers(dp)
+ admin_public_offer.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
diff --git a/app/database/crud/public_offer.py b/app/database/crud/public_offer.py
new file mode 100644
index 00000000..78d5716e
--- /dev/null
+++ b/app/database/crud/public_offer.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 PublicOffer
+
+logger = logging.getLogger(__name__)
+
+
+async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
+ result = await db.execute(
+ select(PublicOffer).where(PublicOffer.language == language)
+ )
+ return result.scalar_one_or_none()
+
+
+async def upsert_public_offer(
+ db: AsyncSession,
+ language: str,
+ content: str,
+ *,
+ enable_if_new: bool = True,
+) -> PublicOffer:
+ offer = await get_public_offer(db, language)
+
+ if offer:
+ offer.content = content or ""
+ offer.updated_at = datetime.utcnow()
+ else:
+ offer = PublicOffer(
+ language=language,
+ content=content or "",
+ is_enabled=True if enable_if_new else False,
+ )
+ db.add(offer)
+
+ await db.commit()
+ await db.refresh(offer)
+
+ logger.info(
+ "✅ Публичная оферта для языка %s обновлена (ID: %s)",
+ language,
+ offer.id,
+ )
+
+ return offer
+
+
+async def set_public_offer_enabled(
+ db: AsyncSession,
+ language: str,
+ enabled: bool,
+) -> PublicOffer:
+ offer = await get_public_offer(db, language)
+
+ if offer:
+ offer.is_enabled = bool(enabled)
+ offer.updated_at = datetime.utcnow()
+ else:
+ offer = PublicOffer(
+ language=language,
+ content="",
+ is_enabled=bool(enabled),
+ )
+ db.add(offer)
+
+ await db.commit()
+ await db.refresh(offer)
+
+ logger.info(
+ "✅ Статус публичной оферты для языка %s обновлен: %s",
+ language,
+ "enabled" if offer.is_enabled else "disabled",
+ )
+
+ return offer
diff --git a/app/database/models.py b/app/database/models.py
index f48f2f47..3536fecb 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -771,6 +771,17 @@ class PrivacyPolicy(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+class PublicOffer(Base):
+ __tablename__ = "public_offers"
+
+ 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 a29a748f..791d5d4d 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -2553,6 +2553,59 @@ async def create_privacy_policies_table() -> bool:
return False
+async def create_public_offers_table() -> bool:
+ table_exists = await check_table_exists("public_offers")
+ if table_exists:
+ logger.info("ℹ️ Таблица public_offers уже существует")
+ return True
+
+ try:
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ if db_type == "sqlite":
+ create_sql = """
+ CREATE TABLE public_offers (
+ 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 public_offers (
+ 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 public_offers (
+ 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("✅ Таблица public_offers создана")
+ return True
+
+ except Exception as error:
+ logger.error(f"❌ Ошибка создания таблицы public_offers: {error}")
+ return False
+
+
async def ensure_default_web_api_token() -> bool:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
@@ -2642,6 +2695,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей privacy_policies")
+ logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===")
+ public_offers_ready = await create_public_offers_table()
+ if public_offers_ready:
+ logger.info("✅ Таблица public_offers готова")
+ else:
+ logger.warning("⚠️ Проблемы с таблицей public_offers")
+
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
default_token_ready = await ensure_default_web_api_token()
if default_token_ready:
@@ -2929,6 +2989,7 @@ async def check_migration_status():
"promo_groups_table": False,
"server_promo_groups_table": False,
"privacy_policies_table": False,
+ "public_offers_table": False,
"users_promo_group_column": False,
"promo_groups_period_discounts_column": False,
"promo_groups_auto_assign_column": False,
@@ -2954,6 +3015,7 @@ async def check_migration_status():
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["public_offers_table"] = await check_table_exists('public_offers')
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')
@@ -3004,6 +3066,7 @@ async def check_migration_status():
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"privacy_policies_table": "Таблица политик конфиденциальности",
+ "public_offers_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/public_offer.py b/app/handlers/admin/public_offer.py
new file mode 100644
index 00000000..141e7de5
--- /dev/null
+++ b/app/handlers/admin/public_offer.py
@@ -0,0 +1,530 @@
+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.public_offer_service import PublicOfferService
+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)
+ offer = await PublicOfferService.get_offer(
+ db,
+ db_user.language,
+ fallback=False,
+ )
+
+ normalized_language = PublicOfferService.normalize_language(db_user.language)
+ has_content = bool(offer and offer.content and offer.content.strip())
+
+ description = texts.t(
+ "ADMIN_PUBLIC_OFFER_DESCRIPTION",
+ "Публичная оферта отображается в разделе «Инфо».",
+ )
+
+ status_text = texts.t(
+ "ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
+ "⚠️ Показ оферты выключен или текст отсутствует.",
+ )
+ if offer and offer.is_enabled and has_content:
+ status_text = texts.t(
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
+ "✅ Оферта активна и показывается пользователям.",
+ )
+ elif offer and offer.is_enabled:
+ status_text = texts.t(
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
+ "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
+ )
+
+ updated_at = _format_timestamp(getattr(offer, "updated_at", None))
+ updated_block = ""
+ if updated_at:
+ updated_block = texts.t(
+ "ADMIN_PUBLIC_OFFER_UPDATED_AT",
+ "Последнее обновление: {timestamp}",
+ ).format(timestamp=updated_at)
+
+ preview_block = texts.t(
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
+ "Текст ещё не задан.",
+ )
+ if has_content:
+ preview_title = texts.t(
+ "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
+ "Превью текста:",
+ )
+ preview_raw = offer.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_PUBLIC_OFFER_LANGUAGE",
+ "Язык: {lang}",
+ ).format(lang=normalized_language)
+
+ header = texts.t(
+ "ADMIN_PUBLIC_OFFER_HEADER",
+ "📄 Публичная оферта",
+ )
+ actions_prompt = texts.t(
+ "ADMIN_PUBLIC_OFFER_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_PUBLIC_OFFER_EDIT_BUTTON",
+ "✏️ Изменить текст",
+ ),
+ callback_data="admin_public_offer_edit",
+ )
+ ])
+
+ if has_content:
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
+ "👀 Просмотреть текущий текст",
+ ),
+ callback_data="admin_public_offer_view",
+ )
+ ])
+
+ toggle_text = texts.t(
+ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
+ "✅ Включить показ",
+ )
+ if offer and offer.is_enabled:
+ toggle_text = texts.t(
+ "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
+ "🚫 Отключить показ",
+ )
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=toggle_text,
+ callback_data="admin_public_offer_toggle",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_HTML_HELP",
+ "ℹ️ HTML помощь",
+ ),
+ callback_data="admin_public_offer_help",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="admin_submenu_settings",
+ )
+ ])
+
+ return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
+
+
+@admin_required
+@error_handler
+async def show_public_offer_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_public_offer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
+ logger.info(
+ "Админ %s переключил показ публичной оферты: %s",
+ db_user.telegram_id,
+ "enabled" if updated_offer.is_enabled else "disabled",
+ )
+ status_message = (
+ texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
+ if updated_offer.is_enabled
+ else texts.t("ADMIN_PUBLIC_OFFER_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_public_offer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ offer = await PublicOfferService.get_offer(
+ db,
+ db_user.language,
+ fallback=False,
+ )
+
+ current_preview = ""
+ if offer and offer.content:
+ preview = offer.content.strip()[:400]
+ if len(offer.content.strip()) > 400:
+ preview += "..."
+ current_preview = (
+ texts.t(
+ "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
+ "Текущий текст (превью):",
+ )
+ + f"\n{html.escape(preview)}\n\n"
+ )
+
+ prompt = texts.t(
+ "ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
+ "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
+ )
+
+ hint = texts.t(
+ "ADMIN_PUBLIC_OFFER_EDIT_HINT",
+ "Используйте /html_help для справки по тегам.",
+ )
+
+ message_text = (
+ f"📝 {texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}\n\n"
+ f"{current_preview}{prompt}\n\n{hint}"
+ )
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_HTML_HELP",
+ "ℹ️ HTML помощь",
+ ),
+ callback_data="admin_public_offer_help",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
+ callback_data="admin_public_offer_cancel",
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(message_text, reply_markup=keyboard)
+ await state.set_state(AdminStates.editing_public_offer)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def cancel_edit_public_offer(
+ 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(
+ get_texts(db_user.language).t(
+ "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
+ "Редактирование оферты отменено.",
+ )
+ )
+
+
+@admin_required
+@error_handler
+async def process_public_offer_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_PUBLIC_OFFER_TOO_LONG",
+ "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
+ )
+ )
+ return
+
+ is_valid, error_message = validate_html_tags(new_text)
+ if not is_valid:
+ await message.answer(
+ texts.t(
+ "ADMIN_PUBLIC_OFFER_HTML_ERROR",
+ "❌ Ошибка в HTML: {error}",
+ ).format(error=error_message)
+ )
+ return
+
+ await PublicOfferService.save_offer(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_PUBLIC_OFFER_SAVED",
+ "✅ Публичная оферта обновлена.",
+ )
+
+ reply_markup = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON",
+ "⬅️ К настройкам оферты",
+ ),
+ callback_data="admin_public_offer",
+ )
+ ]
+ ]
+ )
+
+ await message.answer(success_text, reply_markup=reply_markup)
+
+
+@admin_required
+@error_handler
+async def view_public_offer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ offer = await PublicOfferService.get_offer(
+ db,
+ db_user.language,
+ fallback=False,
+ )
+
+ if not offer or not offer.content or not offer.content.strip():
+ await callback.answer(
+ texts.t(
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
+ "Текст оферты пока не задан.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ content = offer.content.strip()
+ max_length = 3800
+ pages = PublicOfferService.split_content_into_pages(
+ content,
+ max_length=max_length,
+ )
+
+ if not pages:
+ await callback.answer(
+ texts.t(
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
+ "Текст оферты пока не задан.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ preview = pages[0]
+ truncated = len(pages) > 1
+
+ header = texts.t(
+ "ADMIN_PUBLIC_OFFER_VIEW_TITLE",
+ "👀 Текущий текст оферты",
+ )
+
+ note = ""
+ if truncated:
+ note = texts.t(
+ "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
+ "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
+ )
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON",
+ "⬅️ К настройкам оферты",
+ ),
+ callback_data="admin_public_offer",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
+ "✏️ Изменить текст",
+ ),
+ callback_data="admin_public_offer_edit",
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ f"{header}\n\n{preview}{note}",
+ reply_markup=keyboard,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_public_offer_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_public_offer.state:
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
+ "⬅️ Назад к редактированию",
+ ),
+ callback_data="admin_public_offer_edit",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON",
+ "⬅️ К настройкам оферты",
+ ),
+ callback_data="admin_public_offer",
+ )
+ ])
+
+ 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_public_offer_management,
+ F.data == "admin_public_offer",
+ )
+ dp.callback_query.register(
+ toggle_public_offer,
+ F.data == "admin_public_offer_toggle",
+ )
+ dp.callback_query.register(
+ start_edit_public_offer,
+ F.data == "admin_public_offer_edit",
+ )
+ dp.callback_query.register(
+ cancel_edit_public_offer,
+ F.data == "admin_public_offer_cancel",
+ )
+ dp.callback_query.register(
+ view_public_offer,
+ F.data == "admin_public_offer_view",
+ )
+ dp.callback_query.register(
+ show_public_offer_html_help,
+ F.data == "admin_public_offer_help",
+ )
+
+ dp.message.register(
+ process_public_offer_edit,
+ AdminStates.editing_public_offer,
+ )
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index fab18d5b..a15d42c2 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -26,6 +26,7 @@ from app.utils.promo_offer import (
build_test_access_hint,
)
from app.services.privacy_policy_service import PrivacyPolicyService
+from app.services.public_offer_service import PublicOfferService
logger = logging.getLogger(__name__)
@@ -112,6 +113,7 @@ async def show_info_menu(
caption = f"{header}\n\n{prompt}" if prompt else header
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
+ public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
await edit_or_answer_photo(
callback=callback,
@@ -119,6 +121,7 @@ async def show_info_menu(
keyboard=get_info_menu_keyboard(
language=db_user.language,
show_privacy_policy=privacy_enabled,
+ show_public_offer=public_offer_enabled,
),
parse_mode="HTML",
)
@@ -232,6 +235,113 @@ async def show_privacy_policy(
await callback.answer()
+async def show_public_offer(
+ 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
+
+ offer = await PublicOfferService.get_active_offer(db, db_user.language)
+
+ if not offer:
+ await callback.answer(
+ texts.t(
+ "PUBLIC_OFFER_NOT_AVAILABLE",
+ "Публичная оферта временно недоступна.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ pages = PublicOfferService.split_content_into_pages(offer.content)
+
+ if not pages:
+ await callback.answer(
+ texts.t(
+ "PUBLIC_OFFER_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(
+ "PUBLIC_OFFER_HEADER",
+ "📄 Публичная оферта",
+ )
+ body = pages[current_page - 1]
+
+ footer_template = texts.t(
+ "PUBLIC_OFFER_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_public_offer:{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_public_offer:{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,
@@ -511,6 +621,16 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("menu_privacy_policy:"),
)
+ dp.callback_query.register(
+ show_public_offer,
+ F.data == "menu_public_offer",
+ )
+
+ dp.callback_query.register(
+ show_public_offer,
+ F.data.startswith("menu_public_offer:"),
+ )
+
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 72a66fbd..2a59970b 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -184,6 +184,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
callback_data="admin_privacy_policy",
)
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"),
+ callback_data="admin_public_offer",
+ )
+ ],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 5bfa995b..15624917 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -283,6 +283,7 @@ def get_main_menu_keyboard(
def get_info_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
show_privacy_policy: bool = False,
+ show_public_offer: bool = False,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -296,6 +297,14 @@ def get_info_menu_keyboard(
)
])
+ if show_public_offer:
+ buttons.append([
+ InlineKeyboardButton(
+ text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
+ callback_data="menu_public_offer",
+ )
+ ])
+
buttons.append([
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
])
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 35328586..ffa15770 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -720,5 +720,42 @@
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
- "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
+ "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
+ "MENU_PUBLIC_OFFER": "📄 Offer",
+ "PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
+ "PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
+ "PUBLIC_OFFER_HEADER": "📄 Public Offer",
+ "PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
+ "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
+ "ADMIN_PUBLIC_OFFER_HEADER": "📄 Public offer",
+ "ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
+ "ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: {lang}",
+ "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
+ "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Text preview:",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
+ "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
+ "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
+ "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
+ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
+ "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
+ "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
+ "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
+ "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
+ "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
+ "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
+ "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
+ "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
+ "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
+ "ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
+ "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Current offer text",
+ "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
+ "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
+ "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
+ "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
+ "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
}
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index f66baaec..06d7a051 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -235,10 +235,15 @@
"MENU_INFO_HEADER": "ℹ️ Инфо",
"MENU_INFO_PROMPT": "Выберите раздел:",
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
+ "MENU_PUBLIC_OFFER": "📄 Оферта",
"PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности",
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
+ "PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
+ "PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
+ "PUBLIC_OFFER_HEADER": "📄 Публичная оферта",
+ "PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -719,6 +724,38 @@
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
+ "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
+ "ADMIN_PUBLIC_OFFER_HEADER": "📄 Публичная оферта",
+ "ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
+ "ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: {lang}",
+ "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
+ "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Превью текста:",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
+ "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
+ "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
+ "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
+ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
+ "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
+ "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
+ "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
+ "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
+ "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
+ "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
+ "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
+ "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
+ "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
+ "ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
+ "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Текущий текст оферты",
+ "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
+ "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
+ "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
+ "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
+ "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
diff --git a/app/services/public_offer_service.py b/app/services/public_offer_service.py
new file mode 100644
index 00000000..053d48af
--- /dev/null
+++ b/app/services/public_offer_service.py
@@ -0,0 +1,359 @@
+import logging
+from html.parser import HTMLParser
+from typing import List, Optional, Tuple
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.public_offer import (
+ get_public_offer,
+ set_public_offer_enabled,
+ upsert_public_offer,
+)
+from app.database.models import PublicOffer
+
+logger = logging.getLogger(__name__)
+
+
+class PublicOfferService:
+ """Helpers for managing the public offer text and visibility."""
+
+ 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 PublicOfferService._normalize_language(language)
+
+ @classmethod
+ async def get_offer(
+ cls,
+ db: AsyncSession,
+ language: str,
+ *,
+ fallback: bool = False,
+ ) -> Optional[PublicOffer]:
+ lang = cls._normalize_language(language)
+ offer = await get_public_offer(db, lang)
+
+ if offer or not fallback:
+ return offer
+
+ default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
+ if lang != default_lang:
+ return await get_public_offer(db, default_lang)
+
+ return offer
+
+ @classmethod
+ async def get_active_offer(
+ cls,
+ db: AsyncSession,
+ language: str,
+ ) -> Optional[PublicOffer]:
+ lang = cls._normalize_language(language)
+ offer = await get_public_offer(db, lang)
+
+ if offer:
+ if offer.is_enabled and offer.content.strip():
+ return offer
+
+ if not offer.is_enabled:
+ return None
+
+ default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
+ if lang != default_lang:
+ fallback_offer = await get_public_offer(db, default_lang)
+ if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
+ return fallback_offer
+
+ return None
+
+ @classmethod
+ async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
+ offer = await cls.get_active_offer(db, language)
+ return offer is not None
+
+ @classmethod
+ async def save_offer(
+ cls,
+ db: AsyncSession,
+ language: str,
+ content: str,
+ ) -> PublicOffer:
+ lang = cls._normalize_language(language)
+ enable_if_new = True
+ offer = await upsert_public_offer(
+ db,
+ lang,
+ content,
+ enable_if_new=enable_if_new,
+ )
+ logger.info("✅ Публичная оферта обновлена для языка %s", lang)
+ return offer
+
+ @classmethod
+ async def set_enabled(
+ cls,
+ db: AsyncSession,
+ language: str,
+ enabled: bool,
+ ) -> PublicOffer:
+ lang = cls._normalize_language(language)
+ return await set_public_offer_enabled(db, lang, enabled)
+
+ @classmethod
+ async def toggle_enabled(
+ cls,
+ db: AsyncSession,
+ language: str,
+ ) -> PublicOffer:
+ lang = cls._normalize_language(language)
+ offer = await get_public_offer(db, lang)
+
+ if offer:
+ new_status = not offer.is_enabled
+ else:
+ new_status = True
+
+ return await set_public_offer_enabled(db, lang, new_status)
+
+ class _RichTextPaginator(HTMLParser):
+ """Split HTML-like text into Telegram-safe chunks."""
+
+ SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
+
+ def __init__(self, max_len: int) -> None:
+ super().__init__(convert_charrefs=False)
+ self.max_len = max_len
+ self.pages: List[str] = []
+ self.current_parts: List[str] = []
+ self.current_length = 0
+ self.open_stack: List[Tuple[str, str, int]] = []
+ self.closing_length = 0
+ self.needs_prefix = False
+ self.prefix_length = 0
+
+ def _closing_sequence(self) -> str:
+ return "".join(f"{name}>" for name, _, _ in reversed(self.open_stack))
+
+ def _ensure_prefix(self) -> None:
+ if not self.needs_prefix:
+ return
+
+ prefix = "".join(token for _, token, _ in self.open_stack)
+ if prefix:
+ self.current_parts.append(prefix)
+ self.current_length += len(prefix)
+ self.needs_prefix = False
+
+ def _flush(self) -> None:
+ if not self.current_parts and not self.closing_length:
+ return
+
+ content = "".join(self.current_parts)
+ closing_tags = self._closing_sequence()
+ page = (content + closing_tags).strip()
+ if page:
+ self.pages.append(page)
+
+ self.current_parts = []
+ self.current_length = 0
+ if self.prefix_length + self.closing_length > self.max_len:
+ self.open_stack = []
+ self.closing_length = 0
+ self.prefix_length = 0
+ self.needs_prefix = False
+ else:
+ self.needs_prefix = bool(self.open_stack)
+
+ @staticmethod
+ def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
+ parts = []
+ for name, value in attrs:
+ if value is None:
+ parts.append(f" {name}")
+ else:
+ parts.append(f" {name}=\"{value}\"")
+ return "".join(parts)
+
+ def _append_token(self, token: str) -> None:
+ while True:
+ self._ensure_prefix()
+ if self.current_length + len(token) + self.closing_length <= self.max_len:
+ self.current_parts.append(token)
+ self.current_length += len(token)
+ break
+
+ self._flush()
+
+ if len(token) > self.max_len and not self.current_parts:
+ self.current_parts.append(token)
+ self.current_length += len(token)
+ break
+
+ def handle_data(self, data: str) -> None:
+ if not data:
+ return
+
+ remaining = data
+ while remaining:
+ self._ensure_prefix()
+ available = self.max_len - (self.current_length + self.closing_length)
+ if available <= 0:
+ self._flush()
+ continue
+
+ piece = remaining[:available]
+ self.current_parts.append(piece)
+ self.current_length += len(piece)
+ remaining = remaining[available:]
+
+ if remaining:
+ self._flush()
+
+ def handle_entityref(self, name: str) -> None:
+ self.handle_data(f"&{name};")
+
+ def handle_charref(self, name: str) -> None:
+ self.handle_data(f"{name};")
+
+ def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
+ token = f"<{tag}{self._format_attrs(attrs)}/>"
+ self._append_token(token)
+
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
+ if tag in self.SELF_CLOSING_TAGS:
+ self._handle_self_closing(tag, attrs)
+ return
+
+ token = f"<{tag}{self._format_attrs(attrs)}>"
+ closing_token = f"{tag}>"
+ closing_len = len(closing_token)
+
+ while True:
+ self._ensure_prefix()
+ projected_length = self.current_length + len(token) + self.closing_length + closing_len
+ if projected_length <= self.max_len:
+ self.current_parts.append(token)
+ self.current_length += len(token)
+ self.open_stack.append((tag, token, closing_len))
+ self.closing_length += closing_len
+ self.prefix_length += len(token)
+ break
+
+ self._flush()
+
+ if len(token) + closing_len > self.max_len and not self.current_parts:
+ self.current_parts.append(token)
+ self.current_length += len(token)
+ self.open_stack.append((tag, token, closing_len))
+ self.closing_length += closing_len
+ self.prefix_length += len(token)
+ break
+
+ def handle_endtag(self, tag: str) -> None:
+ token = f"{tag}>"
+ closing_len_reduction = 0
+ index_to_remove = None
+ for index in range(len(self.open_stack) - 1, -1, -1):
+ if self.open_stack[index][0] == tag:
+ closing_len_reduction = self.open_stack[index][2]
+ index_to_remove = index
+ break
+
+ while True:
+ self._ensure_prefix()
+ projected_closing_length = self.closing_length - closing_len_reduction
+ if projected_closing_length < 0:
+ projected_closing_length = 0
+
+ projected_total = self.current_length + len(token) + projected_closing_length
+ if projected_total <= self.max_len or not self.current_parts:
+ self.current_parts.append(token)
+ self.current_length += len(token)
+ if index_to_remove is not None:
+ removed_tag = self.open_stack.pop(index_to_remove)
+ self.closing_length -= closing_len_reduction
+ self.prefix_length -= len(removed_tag[1])
+ break
+
+ self._flush()
+
+ def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
+ self._handle_self_closing(tag, attrs)
+
+ def finalize(self) -> List[str]:
+ self._flush()
+ return self.pages
+
+ @classmethod
+ def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
+ if len(paragraph) <= max_len:
+ return [paragraph]
+
+ paginator = cls._RichTextPaginator(max_len)
+ paginator.feed(paragraph)
+ paginator.close()
+ pages = paginator.finalize()
+ return pages or [paragraph]
+
+ @classmethod
+ def split_content_into_pages(
+ cls,
+ 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 cls.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:
+ segments = cls._split_rich_paragraph(paragraph, max_len)
+ for segment in segments:
+ segment = segment.strip()
+ if not segment:
+ continue
+
+ candidate = f"{current}\n\n{segment}".strip() if current else segment
+ if len(candidate) <= max_len:
+ current = candidate
+ continue
+
+ flush_current()
+ current = segment
+
+ flush_current()
+
+ if not pages:
+ return [normalized[:max_len]]
+
+ return pages
diff --git a/app/states.py b/app/states.py
index 1e42df99..7527313b 100644
--- a/app/states.py
+++ b/app/states.py
@@ -88,6 +88,7 @@ class AdminStates(StatesGroup):
editing_rules_page = State()
editing_privacy_policy = State()
+ editing_public_offer = State()
editing_notification_value = State()
confirming_sync = State()
diff --git a/locales/en.json b/locales/en.json
index 54cf63dd..8869e22e 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -1052,5 +1052,42 @@
"ADMIN_PRICING_SETTING_PROMPT": "Send a new value or type \"Cancel\". Use none to clear.",
"ADMIN_PRICING_SETTING_SUCCESS": "Parameter {label} updated: {value}",
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
- "ADMIN_PRICING_SETTING_WARNING": "Important"
+ "ADMIN_PRICING_SETTING_WARNING": "Important",
+ "MENU_PUBLIC_OFFER": "📄 Offer",
+ "PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
+ "PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
+ "PUBLIC_OFFER_HEADER": "📄 Public Offer",
+ "PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
+ "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
+ "ADMIN_PUBLIC_OFFER_HEADER": "📄 Public offer",
+ "ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
+ "ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: {lang}",
+ "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
+ "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Text preview:",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
+ "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
+ "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
+ "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
+ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
+ "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
+ "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
+ "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
+ "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
+ "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
+ "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
+ "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
+ "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
+ "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
+ "ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
+ "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Current offer text",
+ "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
+ "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
+ "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
+ "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
+ "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
}
diff --git a/locales/ru.json b/locales/ru.json
index 32eba83d..868f9e4f 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1016,5 +1016,42 @@
"ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.",
"ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}",
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
- "ADMIN_PRICING_SETTING_WARNING": "Важно"
+ "ADMIN_PRICING_SETTING_WARNING": "Важно",
+ "MENU_PUBLIC_OFFER": "📄 Оферта",
+ "PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
+ "PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
+ "PUBLIC_OFFER_HEADER": "📄 Публичная оферта",
+ "PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
+ "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
+ "ADMIN_PUBLIC_OFFER_HEADER": "📄 Публичная оферта",
+ "ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
+ "ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: {lang}",
+ "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
+ "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
+ "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Превью текста:",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
+ "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
+ "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
+ "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
+ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
+ "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
+ "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
+ "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
+ "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
+ "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
+ "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
+ "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
+ "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
+ "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
+ "ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
+ "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
+ "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
+ "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Текущий текст оферты",
+ "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
+ "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
+ "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
+ "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
+ "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено."
}