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