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,