diff --git a/app/bot/handlers/main.py b/app/bot/handlers/main.py index 21640ea..ea71042 100644 --- a/app/bot/handlers/main.py +++ b/app/bot/handlers/main.py @@ -1,17 +1,18 @@ from __future__ import annotations import html +import asyncio import secrets from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import quote_plus from aiogram import Bot, F, Router -from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.filters import Command, CommandObject, CommandStart from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message +from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, InputMediaPhoto, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from app.bot.ui.panel import ( @@ -50,6 +51,7 @@ from app.services.sync_service import ( AdminTelegramUserProfile, AdminTelegramUsersPage, CachedUserView, + GuideView, PromoCodeView, SyncService, ) @@ -85,6 +87,10 @@ class AdminLookupStates(StatesGroup): waiting_for_identifier = State() +class AdminBroadcastStates(StatesGroup): + waiting_for_text = State() + + class AdminConfigStates(StatesGroup): waiting_for_value = State() @@ -96,6 +102,13 @@ class AdminPromoStates(StatesGroup): waiting_for_expires = State() +class AdminGuideStates(StatesGroup): + waiting_for_title = State() + waiting_for_description = State() + waiting_for_body = State() + waiting_for_photos = State() + + def build_router( *, settings: Settings, @@ -130,6 +143,9 @@ def build_router( def payment_callback(action: str, value: str | None = None) -> str: return f"payment:{action}:{value}" if value else f"payment:{action}" + def guides_callback(action: str, value: str | None = None) -> str: + return f"guides:{action}:{value}" if value else f"guides:{action}" + async def send_branded( bot_instance: Bot, *, @@ -219,11 +235,16 @@ def build_router( ReferralCodeStates.waiting_for_code.state, ManualPaymentStates.waiting_for_receipt.state, AdminLookupStates.waiting_for_identifier.state, + AdminBroadcastStates.waiting_for_text.state, AdminConfigStates.waiting_for_value.state, AdminPromoStates.waiting_for_code.state, AdminPromoStates.waiting_for_plan.state, AdminPromoStates.waiting_for_discount.state, AdminPromoStates.waiting_for_expires.state, + AdminGuideStates.waiting_for_title.state, + AdminGuideStates.waiting_for_description.state, + AdminGuideStates.waiting_for_body.state, + AdminGuideStates.waiting_for_photos.state, }: await state.clear() @@ -494,6 +515,73 @@ def build_router( ) return True + def build_admin_broadcast_preview_keyboard(): + builder = InlineKeyboardBuilder() + builder.button( + text="✅ Отправить всем", + callback_data="admin:broadcast_confirm", + ) + builder.button( + text="❌ Отменить", + callback_data="admin:broadcast_cancel", + ) + builder.adjust(1) + return builder.as_markup() + + def get_message_html_text(message: Message) -> tuple[str, str]: + raw_text = (message.text or message.caption or "").strip() + formatted_text = ( + (getattr(message, "html_text", None) or getattr(message, "html_caption", None) or "").strip() + ) + if not formatted_text and raw_text: + formatted_text = html.escape(raw_text) + return raw_text, formatted_text + + async def show_admin_broadcast_preview(message: Message, *, text_html: str, state: FSMContext) -> None: + recipient_count = len(await sync_service.get_broadcast_telegram_ids()) + await state.update_data(broadcast_text_html=text_html) + await message.answer( + "📣 Предпросмотр рассылки.\n\n" + f"Получателей: {recipient_count}\n" + "Если всё верно, нажмите кнопку подтверждения ниже." + ) + await message.answer( + text_html, + reply_markup=build_admin_broadcast_preview_keyboard(), + ) + + async def send_admin_broadcast(bot_instance: Bot, *, text_html: str) -> tuple[int, int, int]: + telegram_ids = await sync_service.get_broadcast_telegram_ids() + sent_count = 0 + blocked_count = 0 + failed_count = 0 + + for telegram_id in telegram_ids: + try: + await bot_instance.send_message(chat_id=telegram_id, text=text_html) + sent_count += 1 + except TelegramRetryAfter as exc: + await asyncio.sleep(exc.retry_after + 1) + try: + await bot_instance.send_message(chat_id=telegram_id, text=text_html) + sent_count += 1 + except TelegramForbiddenError: + blocked_count += 1 + except TelegramBadRequest: + failed_count += 1 + except Exception: + failed_count += 1 + except TelegramForbiddenError: + blocked_count += 1 + except TelegramBadRequest: + failed_count += 1 + except Exception: + failed_count += 1 + + await asyncio.sleep(0.035) + + return sent_count, blocked_count, failed_count + def admin_settings_callback(action: str, value: str | None = None) -> str: return f"admin:{action}:{value}" if value else f"admin:{action}" @@ -688,6 +776,203 @@ def build_router( builder.adjust(1) return builder.as_markup() + def render_guides_menu_text(*, guides: list[GuideView]) -> str: + lines = [ + "📚 Гайды", + "", + ] + if not guides: + lines.append("
Гайды пока не добавлены.") + return "\n".join(lines) + + lines.append("
Выберите инструкцию ниже.") + for index, guide in enumerate(guides[:10], start=1): + lines.extend( + [ + "", + f"{index}. {html.escape(guide.title)}", + html.escape(guide.short_description or "Без описания"), + ] + ) + return "\n".join(lines) + + def build_guides_menu_keyboard(*, guides: list[GuideView]): + builder = InlineKeyboardBuilder() + for guide in guides[:10]: + builder.button( + text=guide.title, + callback_data=guides_callback("view", str(guide.id)), + ) + builder.button( + text="🏠 Главное меню", + callback_data=f"panel:{PANEL_SECTION_HOME}", + ) + builder.adjust(1) + return builder.as_markup() + + def render_guide_text(*, guide: GuideView) -> str: + lines = [ + f"{html.escape(guide.title)}", + ] + if guide.short_description: + lines.extend(["", html.escape(guide.short_description)]) + lines.extend( + [ + "", + f"
{html.escape(guide.body_text)}", + ] + ) + return "\n".join(lines) + + def build_guide_view_keyboard(): + builder = InlineKeyboardBuilder() + builder.button(text="⬅️ К гайдам", callback_data=guides_callback("menu")) + builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") + builder.adjust(1) + return builder.as_markup() + + async def user_can_open_guides(telegram_id: int) -> bool: + if settings.is_admin(telegram_id): + return True + accesses = await sync_service.get_cached_users_for_telegram(telegram_id) + return any(access.record.status.upper() == "ACTIVE" for access in accesses) + + async def send_guide_view(bot: Bot, *, chat_id: int, guide: GuideView) -> None: + text = render_guide_text(guide=guide) + reply_markup = build_guide_view_keyboard() + photo_file_ids = guide.photo_file_ids[:10] + + if not photo_file_ids: + await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup) + return + + if len(photo_file_ids) == 1: + if len(text) <= 1024: + await bot.send_photo( + chat_id=chat_id, + photo=photo_file_ids[0], + caption=text, + reply_markup=reply_markup, + ) + return + + await bot.send_photo(chat_id=chat_id, photo=photo_file_ids[0]) + await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup) + return + + media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids] + if len(text) <= 1024: + media[0].caption = text + await bot.send_media_group(chat_id=chat_id, media=media) + await bot.send_message( + chat_id=chat_id, + text="Навигация", + reply_markup=reply_markup, + ) + return + + await bot.send_media_group(chat_id=chat_id, media=media) + await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup) + + def render_admin_guides_text(*, guides: list[GuideView]) -> str: + lines = [ + "📚 Гайды", + "", + "
Здесь создаются инструкции, которые увидят пользователи с активной подпиской.", + ] + if not guides: + lines.extend(["", "Гайды ещё не созданы."]) + return "\n".join(lines) + + lines.extend(["", "Последние гайды"]) + for guide in guides[:10]: + photo_text = f"{len(guide.photo_file_ids)} фото" if guide.photo_file_ids else "без фото" + status_text = "активен" if guide.is_active else "скрыт" + lines.extend( + [ + "", + ( + "
" + f"{html.escape(guide.title)}\n" + f"{html.escape(guide.short_description or 'Без описания')}\n" + f"Медиа: {photo_text}\n" + f"Статус: {status_text}" + "" + ), + ] + ) + return "\n".join(lines) + + def render_admin_guide_delete_confirm_text(*, guide: GuideView) -> str: + photo_text = f"{len(guide.photo_file_ids)} фото" if guide.photo_file_ids else "без фото" + return "\n".join( + [ + "🗑 Удалить гайд?", + "", + ( + "
" + f"{html.escape(guide.title)}\n" + f"{html.escape(guide.short_description or 'Без описания')}\n" + f"Медиа: {photo_text}" + "" + ), + "", + "После удаления гайд пропадёт у пользователей, а его фото-ссылки будут удалены из базы.", + ] + ) + + def build_admin_guides_keyboard(*, guides: list[GuideView]): + builder = InlineKeyboardBuilder() + builder.button( + text="➕ Создать гайд", + callback_data=admin_settings_callback("guide_create"), + ) + for guide in guides[:10]: + builder.button( + text=f"🗑 {compact_inline_label(guide.title, limit=24)}", + callback_data=admin_settings_callback("guide_delete", str(guide.id)), + ) + builder.button( + text="↻ Обновить", + callback_data=admin_settings_callback("guides"), + ) + builder.button( + text="🛠 Админ-панель", + callback_data=f"panel:{PANEL_SECTION_ADMIN}", + ) + builder.adjust(1) + return builder.as_markup() + + def build_admin_guide_delete_confirm_keyboard(*, guide_id: int): + builder = InlineKeyboardBuilder() + builder.button( + text="🗑 Да, удалить", + callback_data=admin_settings_callback("guide_delete_confirm", str(guide_id)), + ) + builder.button( + text="⬅️ К гайдам", + callback_data=admin_settings_callback("guides"), + ) + builder.adjust(1) + return builder.as_markup() + + def build_admin_guide_photos_keyboard(*, photo_count: int): + builder = InlineKeyboardBuilder() + builder.button( + text="✅ Сохранить гайд", + callback_data=admin_settings_callback("guide_done"), + ) + builder.button( + text=f"Фото добавлено: {photo_count}", + callback_data=admin_settings_callback("guide_photo_count"), + ) + builder.button( + text="❌ Отмена", + callback_data=admin_settings_callback("guides"), + ) + builder.adjust(1) + return builder.as_markup() + def render_admin_settings_home_text(*, config: BotConfigSnapshot) -> str: lines = [ "⚙️ Настройки бота", @@ -1982,6 +2267,88 @@ def build_router( support_text = config.payment_support_text.strip() or "По вопросам оплаты напишите в поддержку." await message.answer(html.escape(support_text)) + @router.message(Command("broadcast")) + async def cmd_broadcast(message: Message, command: CommandObject, state: FSMContext) -> None: + if not await ensure_sender(message): + return + if not settings.is_admin(message.from_user.id): + await message.answer("Эта команда доступна только администраторам.") + return + + await state.clear() + text = (command.args or "").strip() + if not text: + await state.set_state(AdminBroadcastStates.waiting_for_text) + await message.answer( + "📣 Отправьте текст рассылки следующим сообщением.\n\n" + "Чтобы отменить действие, отправьте
/cancel."
+ )
+ return
+
+ text_html = html.escape(text)
+ if len(text_html) > 4000:
+ await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
+ return
+
+ await state.set_state(AdminBroadcastStates.waiting_for_text)
+ await show_admin_broadcast_preview(message, text_html=text_html, state=state)
+
+ @router.callback_query(F.data == "guides:menu")
+ async def on_guides_menu(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+
+ await clear_interactive_states(state)
+ if not await user_can_open_guides(callback.from_user.id):
+ await callback.answer("Гайды доступны после оплаты подписки.", show_alert=True)
+ return
+
+ guides = await sync_service.get_guides(active_only=True)
+ await show_branded_view(
+ callback,
+ text=render_guides_menu_text(guides=guides),
+ reply_markup=build_guides_menu_keyboard(guides=guides),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("guides:view:"))
+ async def on_guide_view(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+
+ await clear_interactive_states(state)
+ if not await user_can_open_guides(callback.from_user.id):
+ await callback.answer("Гайды доступны после оплаты подписки.", show_alert=True)
+ return
+
+ raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
+ try:
+ guide_id = int(raw_guide_id)
+ except ValueError:
+ await callback.answer("Гайд не найден", show_alert=True)
+ return
+
+ guide = await sync_service.get_guide(guide_id, active_only=True)
+ if guide is None:
+ await callback.answer("Гайд не найден", show_alert=True)
+ return
+
+ try:
+ await send_guide_view(
+ callback.bot,
+ chat_id=callback.from_user.id,
+ guide=guide,
+ )
+ except TelegramBadRequest:
+ await callback.bot.send_message(
+ chat_id=callback.from_user.id,
+ text=render_guide_text(guide=guide),
+ reply_markup=build_guide_view_keyboard(),
+ )
+ await callback.answer()
+
@router.callback_query(F.data == "payment:buy")
@router.callback_query(F.data == "payment:menu")
async def on_payment_menu(callback: CallbackQuery, state: FSMContext) -> None:
@@ -2101,11 +2468,16 @@ def build_router(
@router.message(SupportTicketStates.waiting_for_note, Command("cancel"))
@router.message(SupportTicketAdminStates.waiting_for_note, Command("cancel"))
@router.message(ReferralCodeStates.waiting_for_code, Command("cancel"))
+ @router.message(AdminBroadcastStates.waiting_for_text, Command("cancel"))
@router.message(AdminConfigStates.waiting_for_value, Command("cancel"))
@router.message(AdminPromoStates.waiting_for_code, Command("cancel"))
@router.message(AdminPromoStates.waiting_for_plan, Command("cancel"))
@router.message(AdminPromoStates.waiting_for_discount, Command("cancel"))
@router.message(AdminPromoStates.waiting_for_expires, Command("cancel"))
+ @router.message(AdminGuideStates.waiting_for_title, Command("cancel"))
+ @router.message(AdminGuideStates.waiting_for_description, Command("cancel"))
+ @router.message(AdminGuideStates.waiting_for_body, Command("cancel"))
+ @router.message(AdminGuideStates.waiting_for_photos, Command("cancel"))
async def cancel_interactive_flow(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer("Действие отменено.")
@@ -2469,6 +2841,71 @@ def build_router(
)
await callback.answer()
+ @router.callback_query(F.data == "admin:broadcast")
+ async def on_admin_broadcast(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await state.clear()
+ await state.set_state(AdminBroadcastStates.waiting_for_text)
+ await callback.message.answer(
+ "📣 Отправьте текст рассылки следующим сообщением.\n\n"
+ "Бот покажет предпросмотр и попросит подтвердить отправку.\n"
+ "Чтобы отменить действие, отправьте /cancel."
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data == "admin:broadcast_cancel")
+ async def on_admin_broadcast_cancel(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await state.clear()
+ await callback.answer("Рассылка отменена")
+ if callback.message is not None:
+ await callback.message.answer("Действие отменено.")
+
+ @router.callback_query(F.data == "admin:broadcast_confirm")
+ async def on_admin_broadcast_confirm(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ state_data = await state.get_data()
+ text_html = str(state_data.get("broadcast_text_html") or "").strip()
+ if not text_html:
+ await state.clear()
+ await callback.answer("Текст рассылки не найден", show_alert=True)
+ return
+
+ await state.clear()
+ await callback.answer("Рассылка запущена")
+ if callback.message is not None:
+ await callback.message.answer("📣 Рассылка запущена. Пришлю итог после отправки.")
+
+ sent_count, blocked_count, failed_count = await send_admin_broadcast(
+ callback.bot,
+ text_html=text_html,
+ )
+ if callback.message is not None:
+ await callback.message.answer(
+ "✅ Рассылка завершена.\n\n"
+ f"Отправлено: {sent_count}\n"
+ f"Недоступны/заблокировали бота: {blocked_count}\n"
+ f"Ошибок отправки: {failed_count}"
+ )
+
@router.callback_query(F.data == "admin:sync_all")
async def on_admin_sync_all(callback: CallbackQuery, state: FSMContext) -> None:
if callback.from_user is None or callback.message is None:
@@ -2492,6 +2929,141 @@ def build_router(
)
await update_panel(callback, context=context, section=PANEL_SECTION_ADMIN)
+ @router.callback_query(F.data == "admin:guides")
+ async def on_admin_guides(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ guides = await sync_service.get_guides(active_only=False)
+ await show_branded_view(
+ callback,
+ text=render_admin_guides_text(guides=guides),
+ reply_markup=build_admin_guides_keyboard(guides=guides),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data == "admin:guide_create")
+ async def on_admin_guide_create(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await state.clear()
+ await state.set_state(AdminGuideStates.waiting_for_title)
+ await callback.message.answer(
+ "📚 Отправьте название гайда одним сообщением.\n\n"
+ "Чтобы отменить создание, отправьте /cancel."
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data == "admin:guide_photo_count")
+ async def on_admin_guide_photo_count(callback: CallbackQuery) -> None:
+ await callback.answer("Отправьте ещё фото или сохраните гайд.", show_alert=True)
+
+ @router.callback_query(F.data == "admin:guide_done")
+ async def on_admin_guide_done(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ state_data = await state.get_data()
+ try:
+ guide = await sync_service.create_guide(
+ title=str(state_data.get("guide_title") or ""),
+ short_description=str(state_data.get("guide_description") or ""),
+ body_text=str(state_data.get("guide_body") or ""),
+ photo_file_ids=list(state_data.get("guide_photos") or []),
+ created_by_telegram_id=callback.from_user.id,
+ )
+ except ValueError as exc:
+ await callback.answer(str(exc), show_alert=True)
+ return
+
+ await state.clear()
+ guides = await sync_service.get_guides(active_only=False)
+ await show_branded_view(
+ callback,
+ text=(
+ f"✅ Гайд {html.escape(guide.title)} создан.\n\n"
+ + render_admin_guides_text(guides=guides)
+ ),
+ reply_markup=build_admin_guides_keyboard(guides=guides),
+ )
+ await callback.answer("Гайд сохранён")
+
+ @router.callback_query(F.data.startswith("admin:guide_delete:"))
+ async def on_admin_guide_delete(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
+ try:
+ guide_id = int(raw_guide_id)
+ except ValueError:
+ await callback.answer("Гайд не найден", show_alert=True)
+ return
+
+ guide = await sync_service.get_guide(guide_id, active_only=False)
+ if guide is None:
+ await callback.answer("Гайд не найден", show_alert=True)
+ return
+
+ await show_branded_view(
+ callback,
+ text=render_admin_guide_delete_confirm_text(guide=guide),
+ reply_markup=build_admin_guide_delete_confirm_keyboard(guide_id=guide.id),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("admin:guide_delete_confirm:"))
+ async def on_admin_guide_delete_confirm(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
+ try:
+ guide_id = int(raw_guide_id)
+ except ValueError:
+ await callback.answer("Гайд не найден", show_alert=True)
+ return
+
+ deleted_guide = await sync_service.delete_guide(guide_id)
+ if deleted_guide is None:
+ await callback.answer("Гайд уже удалён", show_alert=True)
+ return
+
+ guides = await sync_service.get_guides(active_only=False)
+ await show_branded_view(
+ callback,
+ text=(
+ f"✅ Гайд {html.escape(deleted_guide.title)} удалён.\n\n"
+ + render_admin_guides_text(guides=guides)
+ ),
+ reply_markup=build_admin_guides_keyboard(guides=guides),
+ )
+ await callback.answer("Гайд удалён")
+
@router.callback_query(F.data == "admin:promos")
async def on_admin_promos(callback: CallbackQuery, state: FSMContext) -> None:
if callback.from_user is None:
@@ -3219,6 +3791,141 @@ def build_router(
)
await callback.answer()
+ @router.message(AdminGuideStates.waiting_for_title)
+ async def on_admin_guide_title_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ title = (message.text or message.caption or "").strip()
+ if not title:
+ await message.answer("Отправьте название гайда текстом.")
+ return
+ if len(title) > 255:
+ await message.answer("Название должно быть не длиннее 255 символов.")
+ return
+
+ await state.set_state(AdminGuideStates.waiting_for_description)
+ await state.update_data(guide_title=title)
+ await message.answer(
+ "Теперь отправьте короткое описание гайда.\n\n"
+ "Оно будет видно в списке гайдов."
+ )
+
+ @router.message(AdminGuideStates.waiting_for_description)
+ async def on_admin_guide_description_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ description = (message.text or message.caption or "").strip()
+ if not description:
+ await message.answer("Отправьте короткое описание текстом.")
+ return
+ if len(description) > 512:
+ await message.answer("Описание должно быть не длиннее 512 символов.")
+ return
+
+ await state.set_state(AdminGuideStates.waiting_for_body)
+ await state.update_data(guide_description=description)
+ await message.answer(
+ "Теперь отправьте сам гайд одним сообщением.\n\n"
+ "Бот покажет этот текст пользователю как цитату."
+ )
+
+ @router.message(AdminGuideStates.waiting_for_body)
+ async def on_admin_guide_body_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ body_text = (message.text or message.caption or "").strip()
+ if not body_text:
+ await message.answer("Отправьте текст гайда.")
+ return
+
+ await state.set_state(AdminGuideStates.waiting_for_photos)
+ await state.update_data(guide_body=body_text, guide_photos=[])
+ await message.answer(
+ "Теперь отправьте фото для гайда.\n\n"
+ "Можно отправить несколько фото одним альбомом или по одному. "
+ "Когда закончите, нажмите кнопку сохранения или отправьте /done.",
+ reply_markup=build_admin_guide_photos_keyboard(photo_count=0),
+ )
+
+ @router.message(AdminGuideStates.waiting_for_photos, Command("done"))
+ async def on_admin_guide_done_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ state_data = await state.get_data()
+ try:
+ guide = await sync_service.create_guide(
+ title=str(state_data.get("guide_title") or ""),
+ short_description=str(state_data.get("guide_description") or ""),
+ body_text=str(state_data.get("guide_body") or ""),
+ photo_file_ids=list(state_data.get("guide_photos") or []),
+ created_by_telegram_id=message.from_user.id,
+ )
+ except ValueError as exc:
+ await message.answer(html.escape(str(exc)))
+ return
+
+ await state.clear()
+ guides = await sync_service.get_guides(active_only=False)
+ await message.answer(f"✅ Гайд {html.escape(guide.title)} создан.")
+ await message.answer(
+ render_admin_guides_text(guides=guides),
+ reply_markup=build_admin_guides_keyboard(guides=guides),
+ )
+
+ @router.message(AdminGuideStates.waiting_for_photos)
+ async def on_admin_guide_photo_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ if not message.photo:
+ await message.answer(
+ "Отправьте фото или завершите создание командой /done.",
+ reply_markup=build_admin_guide_photos_keyboard(
+ photo_count=len((await state.get_data()).get("guide_photos") or []),
+ ),
+ )
+ return
+
+ state_data = await state.get_data()
+ photo_file_ids = list(state_data.get("guide_photos") or [])
+ file_id = message.photo[-1].file_id
+ if file_id not in photo_file_ids:
+ photo_file_ids.append(file_id)
+ await state.update_data(guide_photos=photo_file_ids)
+ await message.answer(
+ f"Фото добавлено: {len(photo_file_ids)}.",
+ reply_markup=build_admin_guide_photos_keyboard(photo_count=len(photo_file_ids)),
+ )
+
@router.message(AdminPromoStates.waiting_for_code)
async def on_admin_promo_code_message(message: Message, state: FSMContext) -> None:
if not await ensure_sender(message):
@@ -3362,6 +4069,26 @@ def build_router(
if found:
await state.clear()
+ @router.message(AdminBroadcastStates.waiting_for_text)
+ async def on_admin_broadcast_text(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ raw_text, text_html = get_message_html_text(message)
+ if not raw_text:
+ await message.answer("Отправьте текст рассылки одним сообщением.")
+ return
+ if len(text_html) > 4000:
+ await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
+ return
+
+ await show_admin_broadcast_preview(message, text_html=text_html, state=state)
+
@router.message(AdminConfigStates.waiting_for_value)
async def on_admin_setting_value(message: Message, state: FSMContext) -> None:
if not await ensure_sender(message):
diff --git a/app/bot/ui/panel.py b/app/bot/ui/panel.py
index d6de9e3..ee56750 100644
--- a/app/bot/ui/panel.py
+++ b/app/bot/ui/panel.py
@@ -145,6 +145,11 @@ def build_panel_keyboard(
text="💳 Подписка",
callback_data=_payment_callback("menu"),
)
+ if _active_users_count(context.users) > 0:
+ builder.button(
+ text="📚 Гайды",
+ callback_data="guides:menu",
+ )
if context.is_admin:
builder.button(
text="🛠 Админ-панель",
@@ -210,6 +215,14 @@ def build_panel_keyboard(
text="🎟 Промокоды",
callback_data=_admin_callback("promos"),
)
+ builder.button(
+ text="📚 Гайды",
+ callback_data=_admin_callback("guides"),
+ )
+ builder.button(
+ text="📣 Рассылка",
+ callback_data=_admin_callback("broadcast"),
+ )
builder.button(
text="🔄 Полная синхронизация",
callback_data=_admin_callback("sync_all"),
@@ -227,7 +240,7 @@ def build_panel_keyboard(
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
- builder.adjust(2, 2, 2, 2, 1)
+ builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
if section == PANEL_SECTION_SUBSCRIPTION:
diff --git a/app/config.py b/app/config.py
index 8c6581e..daee204 100644
--- a/app/config.py
+++ b/app/config.py
@@ -397,6 +397,7 @@ class Settings(BaseSettings):
continue
title, description, squad_groups = spec
+ use_duration_suffix = len(pairs) > 1
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
continue
@@ -406,7 +407,7 @@ class Settings(BaseSettings):
if days <= 0 or amount_rub <= 0:
continue
- plan_code = f"{normalized_code}_{days}"
+ plan_code = f"{normalized_code}_{days}" if use_duration_suffix else normalized_code
values.append(
PaymentPlanSettings(
code=plan_code,
diff --git a/app/db/models.py b/app/db/models.py
index 1786a28..f3b19f5 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -108,6 +108,33 @@ class SubscriptionRequestLog(Base):
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+class Guide(Base):
+ __tablename__ = "guides"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ short_description: Mapped[str] = mapped_column(String(512), nullable=False, default="")
+ body_text: Mapped[str] = mapped_column(Text, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
+ created_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
+
+
+class GuidePhoto(Base):
+ __tablename__ = "guide_photos"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ guide_id: Mapped[int] = mapped_column(
+ ForeignKey("guides.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ file_id: Mapped[str] = mapped_column(String(512), nullable=False)
+ position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+
+
class SupportTicket(Base):
__tablename__ = "support_tickets"
__table_args__ = (
diff --git a/app/services/bot_config_service.py b/app/services/bot_config_service.py
index 9aeec17..d60bf93 100644
--- a/app/services/bot_config_service.py
+++ b/app/services/bot_config_service.py
@@ -182,6 +182,7 @@ class BotConfigSnapshot:
continue
title, description, squad_groups = spec
+ use_duration_suffix = len(pairs) > 1
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
continue
@@ -191,7 +192,7 @@ class BotConfigSnapshot:
if days <= 0 or amount_rub <= 0:
continue
- plan_code = f"{normalized_code}_{days}"
+ plan_code = f"{normalized_code}_{days}" if use_duration_suffix else normalized_code
values.append(
PaymentPlanSettings(
code=plan_code,
@@ -917,6 +918,7 @@ class BotConfigService(StaticBotConfigService):
raise ValueError("Коды подписок не должны повторяться.")
title, description, squad_groups = spec
+ use_duration_suffix = len(pairs) > 1
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
raise ValueError("Дни и цена должны быть целыми положительными числами.")
@@ -926,7 +928,7 @@ class BotConfigService(StaticBotConfigService):
if days <= 0 or amount_rub <= 0:
raise ValueError("У подписок дни и цена должны быть больше нуля.")
- plan_code = f"{normalized_code}_{days}"
+ plan_code = f"{normalized_code}_{days}" if use_duration_suffix else normalized_code
values.append(
PaymentPlanSettings(
code=plan_code,
diff --git a/app/services/payment_service.py b/app/services/payment_service.py
index 570d0cf..6a8e1b9 100644
--- a/app/services/payment_service.py
+++ b/app/services/payment_service.py
@@ -39,15 +39,17 @@ class AppliedDiscount:
plan_code: str | None = None
expires_at: datetime | None = None
- def applies_to_plan(self, plan_code: str) -> bool:
+ def applies_to_plan(self, plan_code: str, *, days: int | None = None) -> bool:
if not self.code:
return False
if self.plan_code is None:
return True
- # "all_30" matches any product with 30 days (vpn_white_30, white_30, vpn_30)
+ # "all_30" matches any product with 30 days, including single-duration product codes.
if self.plan_code.startswith("all_"):
- suffix = self.plan_code.removeprefix("all") # "_30"
- return plan_code.endswith(suffix)
+ raw_days = self.plan_code.removeprefix("all_")
+ return raw_days.isdigit() and (
+ plan_code.endswith(f"_{raw_days}") or days == int(raw_days)
+ )
# Exact match or product-prefix match (e.g. promo plan_code="vpn_white" matches plan "vpn_white_30")
return self.plan_code == plan_code or plan_code.startswith(f"{self.plan_code}_")
@@ -535,7 +537,10 @@ class PaymentService:
) -> PaymentPlan:
resolved_discount = (
applied_discount
- if applied_discount.applies_to_plan(plan_settings.code)
+ if applied_discount.applies_to_plan(
+ plan_settings.code,
+ days=plan_settings.days,
+ )
else AppliedDiscount()
)
discount_percent = max(resolved_discount.discount_percent, 0)
diff --git a/app/services/sync_service.py b/app/services/sync_service.py
index db0d5e0..c490d96 100644
--- a/app/services/sync_service.py
+++ b/app/services/sync_service.py
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
+ Guide,
+ GuidePhoto,
InternalSquad,
PromoCode,
PromoCodeApplication,
@@ -65,6 +67,19 @@ class PromoCodeView:
return self.is_active and not self.is_expired and self.discount_percent > 0
+@dataclass(slots=True)
+class GuideView:
+ id: int
+ title: str
+ short_description: str
+ body_text: str
+ photo_file_ids: list[str] = field(default_factory=list)
+ is_active: bool = True
+ created_by_telegram_id: int | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+
@dataclass(slots=True)
class AdminStats:
total_telegram_users: int = 0
@@ -268,6 +283,101 @@ class SyncService:
promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
return self._promo_code_view(promo) if promo is not None else None
+ async def create_guide(
+ self,
+ *,
+ title: str,
+ short_description: str,
+ body_text: str,
+ photo_file_ids: list[str],
+ created_by_telegram_id: int,
+ ) -> GuideView:
+ resolved_title = " ".join((title or "").split()).strip()
+ if not resolved_title:
+ raise ValueError("Введите название гайда.")
+ if len(resolved_title) > 255:
+ raise ValueError("Название гайда должно быть не длиннее 255 символов.")
+
+ resolved_description = " ".join((short_description or "").split()).strip()
+ if len(resolved_description) > 512:
+ raise ValueError("Короткое описание должно быть не длиннее 512 символов.")
+
+ resolved_body = (body_text or "").strip()
+ if not resolved_body:
+ raise ValueError("Введите текст гайда.")
+
+ resolved_photos: list[str] = []
+ for file_id in photo_file_ids:
+ normalized_file_id = str(file_id or "").strip()
+ if normalized_file_id and normalized_file_id not in resolved_photos:
+ resolved_photos.append(normalized_file_id)
+
+ async with self._session_factory() as session:
+ guide = Guide(
+ title=resolved_title,
+ short_description=resolved_description,
+ body_text=resolved_body,
+ is_active=True,
+ created_by_telegram_id=created_by_telegram_id,
+ )
+ session.add(guide)
+ await session.flush()
+
+ for position, file_id in enumerate(resolved_photos):
+ session.add(
+ GuidePhoto(
+ guide_id=guide.id,
+ file_id=file_id,
+ position=position,
+ )
+ )
+
+ await session.commit()
+ return self._guide_view(guide, resolved_photos)
+
+ async def get_guides(self, *, active_only: bool = True, limit: int = 50) -> list[GuideView]:
+ resolved_limit = max(1, min(limit, 100))
+ async with self._session_factory() as session:
+ query = (
+ select(Guide, GuidePhoto.file_id)
+ .outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
+ .order_by(Guide.created_at.desc(), Guide.id.desc(), GuidePhoto.position.asc())
+ .limit(resolved_limit * 10)
+ )
+ if active_only:
+ query = query.where(Guide.is_active == True) # noqa: E712
+ rows = (await session.execute(query)).all()
+
+ guides = self._build_guide_views(rows)
+ return guides[:resolved_limit]
+
+ async def get_guide(self, guide_id: int, *, active_only: bool = True) -> GuideView | None:
+ async with self._session_factory() as session:
+ query = (
+ select(Guide, GuidePhoto.file_id)
+ .outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
+ .where(Guide.id == guide_id)
+ .order_by(GuidePhoto.position.asc())
+ )
+ if active_only:
+ query = query.where(Guide.is_active == True) # noqa: E712
+ rows = (await session.execute(query)).all()
+
+ guides = self._build_guide_views(rows)
+ return guides[0] if guides else None
+
+ async def delete_guide(self, guide_id: int) -> GuideView | None:
+ guide = await self.get_guide(guide_id, active_only=False)
+ if guide is None:
+ return None
+
+ async with self._session_factory() as session:
+ await session.execute(delete(GuidePhoto).where(GuidePhoto.guide_id == guide_id))
+ await session.execute(delete(Guide).where(Guide.id == guide_id))
+ await session.commit()
+
+ return guide
+
async def consume_discount_for_user(self, telegram_id: int) -> None:
"""Burn the applied promo / referral discount so it cannot be reused.
@@ -452,6 +562,15 @@ class SyncService:
active_cached_users=int(active_cached_users or 0),
)
+ async def get_broadcast_telegram_ids(self) -> list[int]:
+ async with self._session_factory() as session:
+ rows = await session.scalars(
+ select(TelegramUser.telegram_id)
+ .where(TelegramUser.is_blocked == False) # noqa: E712
+ .order_by(TelegramUser.id.asc())
+ )
+ return list(rows.all())
+
async def get_admin_telegram_users_page(
self,
*,
@@ -1073,6 +1192,32 @@ class SyncService:
updated_at=promo.updated_at,
)
+ @staticmethod
+ def _guide_view(guide: Guide, photo_file_ids: list[str]) -> GuideView:
+ return GuideView(
+ id=guide.id,
+ title=guide.title,
+ short_description=guide.short_description,
+ body_text=guide.body_text,
+ photo_file_ids=photo_file_ids,
+ is_active=bool(guide.is_active),
+ created_by_telegram_id=guide.created_by_telegram_id,
+ created_at=guide.created_at,
+ updated_at=guide.updated_at,
+ )
+
+ @classmethod
+ def _build_guide_views(cls, rows: list[tuple[Guide, str | None]]) -> list[GuideView]:
+ guide_map: dict[int, GuideView] = {}
+ for guide, file_id in rows:
+ view = guide_map.get(guide.id)
+ if view is None:
+ view = cls._guide_view(guide, [])
+ guide_map[guide.id] = view
+ if file_id and file_id not in view.photo_file_ids:
+ view.photo_file_ids.append(file_id)
+ return list(guide_map.values())
+
@staticmethod
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
view_map: dict[int, CachedUserView] = {}
diff --git a/assets/main.png b/assets/main.png
index 68fe0de..a9fb8c1 100644
Binary files a/assets/main.png and b/assets/main.png differ
diff --git a/sql/schema.sql b/sql/schema.sql
index ff39b86..effbb19 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -95,6 +95,32 @@ CREATE TABLE IF NOT EXISTS subscription_request_logs (
INDEX ix_subscription_request_logs_request_at (request_at)
);
+CREATE TABLE IF NOT EXISTS guides (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ short_description VARCHAR(512) NOT NULL DEFAULT '',
+ body_text TEXT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_by_telegram_id BIGINT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ INDEX ix_guides_is_active (is_active),
+ INDEX ix_guides_created_by_telegram_id (created_by_telegram_id)
+);
+
+CREATE TABLE IF NOT EXISTS guide_photos (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ guide_id INT NOT NULL,
+ file_id VARCHAR(512) NOT NULL,
+ position INT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL,
+ CONSTRAINT fk_guide_photos_guide
+ FOREIGN KEY (guide_id)
+ REFERENCES guides (id)
+ ON DELETE CASCADE,
+ INDEX ix_guide_photos_guide_id (guide_id)
+);
+
CREATE TABLE IF NOT EXISTS support_tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
public_id VARCHAR(32) NOT NULL UNIQUE,