add newsletter

This commit is contained in:
2026-05-20 10:11:47 +03:00
parent 2cc2f12a21
commit 5c7a9710f3
9 changed files with 957 additions and 11 deletions

View File

@@ -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"Получателей: <b>{recipient_count}</b>\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 = [
"<b>📚 Гайды</b>",
"",
]
if not guides:
lines.append("<blockquote>Гайды пока не добавлены.</blockquote>")
return "\n".join(lines)
lines.append("<blockquote>Выберите инструкцию ниже.</blockquote>")
for index, guide in enumerate(guides[:10], start=1):
lines.extend(
[
"",
f"<b>{index}. {html.escape(guide.title)}</b>",
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"<b>{html.escape(guide.title)}</b>",
]
if guide.short_description:
lines.extend(["", html.escape(guide.short_description)])
lines.extend(
[
"",
f"<blockquote>{html.escape(guide.body_text)}</blockquote>",
]
)
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 = [
"<b>📚 Гайды</b>",
"",
"<blockquote>Здесь создаются инструкции, которые увидят пользователи с активной подпиской.</blockquote>",
]
if not guides:
lines.extend(["", "<i>Гайды ещё не созданы.</i>"])
return "\n".join(lines)
lines.extend(["", "<b>Последние гайды</b>"])
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(
[
"",
(
"<blockquote>"
f"<b>{html.escape(guide.title)}</b>\n"
f"{html.escape(guide.short_description or 'Без описания')}\n"
f"Медиа: <b>{photo_text}</b>\n"
f"Статус: <b>{status_text}</b>"
"</blockquote>"
),
]
)
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(
[
"<b>🗑 Удалить гайд?</b>",
"",
(
"<blockquote>"
f"<b>{html.escape(guide.title)}</b>\n"
f"{html.escape(guide.short_description or 'Без описания')}\n"
f"Медиа: <b>{photo_text}</b>"
"</blockquote>"
),
"",
"<i>После удаления гайд пропадёт у пользователей, а его фото-ссылки будут удалены из базы.</i>",
]
)
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 = [
"<b>⚙️ Настройки бота</b>",
@@ -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"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
)
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"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
)
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"Отправлено: <b>{sent_count}</b>\n"
f"Недоступны/заблокировали бота: <b>{blocked_count}</b>\n"
f"Ошибок отправки: <b>{failed_count}</b>"
)
@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"
"Чтобы отменить создание, отправьте <code>/cancel</code>."
)
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"✅ Гайд <b>{html.escape(guide.title)}</b> создан.\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"✅ Гайд <b>{html.escape(deleted_guide.title)}</b> удалён.\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"
"Можно отправить несколько фото одним альбомом или по одному. "
"Когда закончите, нажмите кнопку сохранения или отправьте <code>/done</code>.",
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"✅ Гайд <b>{html.escape(guide.title)}</b> создан.")
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(
"Отправьте фото или завершите создание командой <code>/done</code>.",
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"Фото добавлено: <b>{len(photo_file_ids)}</b>.",
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):

View File

@@ -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:

View File

@@ -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,

View File

@@ -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__ = (

View File

@@ -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,

View File

@@ -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)

View File

@@ -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] = {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -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,