diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index 48ec0f0..c0e66b3 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -19,6 +19,7 @@ from bot.keyboards.inline.admin_keyboards import ( ) from bot.middlewares.i18n import JsonI18n from bot.utils.message_queue import get_queue_manager +from bot.utils import get_message_content, send_message_by_type, send_message_via_queue, MessageContent router = Router(name="admin_broadcast_router") @@ -76,30 +77,34 @@ async def process_broadcast_message_handler( _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - # Сохраняем в state исходный текст и entities - text = (message.text or message.caption or "").strip() + # Определяем тип содержимого и сохраняем данные в state entities = message.entities or message.caption_entities or [] + content = get_message_content(message) - # Если текст пустой (например, прислали стикер/фото без подписи) — просим ввести текст - if not text: + # Если нет ни текста, ни медиа — ошибка + if not content.text and not content.file_id: await message.answer(_("admin_broadcast_error_no_message")) return - # Предварительная проверка HTML: попробуем отправить и сразу удалить - # Если HTML некорректный, Telegram вернёт ошибку парсинга + # Сохраняем данные для рассылки + await state.update_data( + broadcast_text=content.text, + broadcast_entities=entities, + broadcast_content_type=content.content_type, + broadcast_file_id=content.file_id, + broadcast_target="all", + ) + + # Отправляем превью-копию того, что будет разослано try: - test_msg = await bot.send_message( - chat_id=message.chat.id, - text=text, + await send_message_by_type( + bot, + chat_id=message.chat.id, + content=content, parse_mode="HTML", disable_web_page_preview=True, disable_notification=True, ) - # Удалим тестовое сообщение - try: - await bot.delete_message(chat_id=message.chat.id, message_id=test_msg.message_id) - except Exception: - pass except TelegramBadRequest as e: await message.answer( _( @@ -110,13 +115,8 @@ async def process_broadcast_message_handler( ) return - await state.update_data( - broadcast_text=text, - broadcast_entities=entities, - broadcast_target="all", - ) - - confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=text) + # Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью + confirmation_prompt = _("admin_broadcast_confirm_prompt_short") await message.answer( confirmation_prompt, @@ -148,10 +148,9 @@ async def change_broadcast_target_handler( await state.update_data(broadcast_target=new_target) user_fsm_data = await state.get_data() - text = user_fsm_data.get("broadcast_text", "") _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) confirmation_prompt = _( - "admin_broadcast_confirm_prompt", message_preview=text + "admin_broadcast_confirm_prompt_short" ) try: await callback.message.edit_text( @@ -221,10 +220,15 @@ async def confirm_broadcast_callback_handler( user_fsm_data = await state.get_data() if action == "send": - text = user_fsm_data.get("broadcast_text") + # Создаем объект контента из сохраненных данных + content = MessageContent( + content_type=user_fsm_data.get("broadcast_content_type", "text"), + file_id=user_fsm_data.get("broadcast_file_id"), + text=user_fsm_data.get("broadcast_text") + ) entities = user_fsm_data.get("broadcast_entities", []) - - if not text: + + if not content.text and content.content_type == "text": await callback.message.edit_text(_("admin_broadcast_error_no_message")) await state.clear() await callback.answer( @@ -247,7 +251,7 @@ async def confirm_broadcast_callback_handler( failed_count = 0 admin_user = callback.from_user logging.info( - f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users." + f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." ) # Get message queue manager @@ -259,9 +263,10 @@ async def confirm_broadcast_callback_handler( # Queue all messages for sending for uid in user_ids: try: - await queue_manager.send_message( - chat_id=uid, - text=text, + await send_message_via_queue( + queue_manager, + uid, + content, parse_mode="HTML", disable_web_page_preview=True, ) @@ -275,7 +280,7 @@ async def confirm_broadcast_callback_handler( "telegram_username": admin_user.username, "telegram_first_name": admin_user.first_name, "event_type": "admin_broadcast_queued", - "content": f"To user {uid}: {text[:70]}...", + "content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", "is_admin_event": True, "target_user_id": uid, }, diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index b83b711..24de068 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -1,6 +1,7 @@ import logging import re from aiogram import Router, F, types, Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.utils.markdown import hcode, hbold from typing import Optional, Dict, Any @@ -15,6 +16,7 @@ from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboar from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n +from bot.utils import get_message_content, send_direct_message from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton router = Router(name="admin_user_management_router") @@ -578,7 +580,7 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo await state.clear() -@router.message(AdminStates.waiting_for_direct_message_to_user, F.text) +@router.message(AdminStates.waiting_for_direct_message_to_user) async def process_direct_message_handler(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, bot: Bot, session: AsyncSession): @@ -597,8 +599,9 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte await state.clear() return - message_text = message.text.strip() - if len(message_text) > 4000: + # Determine content similar to broadcast + text = (message.text or message.caption or "").strip() + if len(text) > 4000: await message.answer(_( "admin_user_message_too_long", default="❌ Сообщение слишком длинное (максимум 4000 символов)" @@ -613,15 +616,40 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte await state.clear() return - # Prepare message with admin signature + # Prepare admin signature and get content admin_signature = _( "admin_direct_message_signature", default="\n\n---\n💬 Сообщение от администратора" ) - full_message = message_text + admin_signature + + content = get_message_content(message) - # Send message to user - await bot.send_message(target_user_id, full_message) + if not content.text and not content.file_id: + await message.answer(_( + "admin_direct_empty_message", + default="❌ Пустое сообщение. Отправьте текст или медиа." + )) + return + + caption_with_signature = (content.text + admin_signature) if content.text else None + + # Send to target user using our fancy match/case function + try: + await send_direct_message( + bot, + target_user_id, + content, + extra_text=admin_signature, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except TelegramBadRequest as e: + await message.answer(_( + "admin_broadcast_invalid_html", + default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}", + error=str(e), + )) + return # Confirm to admin await message.answer(_( diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index e56079e..fe0d90b 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -165,24 +165,25 @@ async def start_command_handler(message: types.Message, "registration_date": datetime.now(timezone.utc) } try: - db_user = await user_dal.create_user(session, user_data_to_create) + db_user, created = await user_dal.create_user(session, user_data_to_create) - logging.info( - f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." - ) - - # Send notification about new user registration - try: - from bot.services.notification_service import NotificationService - notification_service = NotificationService(message.bot, settings, i18n) - await notification_service.notify_new_user_registration( - user_id=user_id, - username=user.username, - first_name=user.first_name, - referred_by_id=referred_by_user_id + if created: + logging.info( + f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." ) - except Exception as e: - logging.error(f"Failed to send new user notification: {e}") + + # Send notification about new user registration + try: + from bot.services.notification_service import NotificationService + notification_service = NotificationService(message.bot, settings, i18n) + await notification_service.notify_new_user_registration( + user_id=user_id, + username=user.username, + first_name=user.first_name, + referred_by_id=referred_by_user_id + ) + except Exception as e: + logging.error(f"Failed to send new user notification: {e}") except Exception as e_create: logging.error( diff --git a/bot/main_bot.py b/bot/main_bot.py index 8b7955a..86981b8 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -107,10 +107,10 @@ async def on_startup_configured(dispatcher: Dispatcher): "STARTUP: Skipped setting Telegram webhook due to security or configuration error." ) else: - logging.info( - "STARTUP: WEBHOOK_BASE_URL not set in environment. Running in polling mode and clearing any existing webhook." + logging.error( + "STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting." ) - await bot.delete_webhook(drop_pending_updates=True) + raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") if settings.SUBSCRIPTION_MINI_APP_URL: try: @@ -271,54 +271,30 @@ async def run_bot(settings_param: Settings): await register_all_routers(dp, settings_param) tg_webhook_base = settings_param.WEBHOOK_BASE_URL - yk_webhook_base = settings_param.WEBHOOK_BASE_URL - should_run_aiohttp_server = bool(tg_webhook_base) or ( - bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path) - ) - - telegram_uses_webhook_mode = bool(tg_webhook_base) - run_telegram_polling = not telegram_uses_webhook_mode + # Webhook mode is now required - exit if not configured + if not tg_webhook_base: + logging.error("WEBHOOK_BASE_URL is required. Polling mode is disabled. Exiting.") + await dp.emit_shutdown() + raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") logging.info(f"--- Bot Run Mode Decision ---") - logging.info( - f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}" - ) - logging.info( - f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'" - ) - logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}") - logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}") + logging.info(f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Webhook Mode: ENABLED") + logging.info(f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'") + logging.info(f"Decision: Run AIOHTTP server: ENABLED (required for webhooks)") logging.info(f"--- End Bot Run Mode Decision ---") web_app_runner = None main_tasks = [] - if should_run_aiohttp_server: - async def web_server_task(): - await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) + # Only run AIOHTTP server for webhook mode + async def web_server_task(): + await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) - main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) + main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) - if run_telegram_polling: - logging.info("Starting bot in Telegram Polling mode...") - main_tasks.append( - asyncio.create_task( - dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()), - name="TelegramPollingTask", - ) - ) - - if not main_tasks: - logging.error( - "Bot is not configured for any run mode (neither Webhook nor Polling). Exiting." - ) - await dp.emit_shutdown() - return - - logging.info( - f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}" - ) + logging.info("Starting bot in Webhook mode with AIOHTTP server...") + logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") try: await asyncio.gather(*main_tasks) diff --git a/bot/routers.py b/bot/routers.py index c79b963..fdcb716 100644 --- a/bot/routers.py +++ b/bot/routers.py @@ -1,4 +1,4 @@ -from aiogram import Router +from aiogram import Router, F from bot.handlers.user import user_router_aggregate from bot.handlers import inline_mode @@ -10,6 +10,10 @@ from config.settings import Settings def build_root_router(settings: Settings) -> Router: root = Router(name="root") + # Allow all updates only in private chats (messages, callback queries, etc.) + root.message.filter(F.chat.type == "private") + root.callback_query.filter(F.message.chat.type == "private") + # Public routers root.include_router(user_router_aggregate) root.include_router(inline_mode.router) diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index 8128c7b..b94f4c8 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -11,6 +11,7 @@ from config.settings import Settings from bot.middlewares.i18n import JsonI18n from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from db.dal import user_dal +from bot.utils.date_utils import add_months EVENT_MAP = { "user.expires_in_72_hours": (3, "subscription_72h_notification"), @@ -48,7 +49,7 @@ class PanelWebhookService: Returns True if an auto-renewal was performed (and renewal message sent), False otherwise. """ from db.dal import subscription_dal, payment_dal - from datetime import datetime, timezone, timedelta + from datetime import datetime, timezone try: auto_renewed = False @@ -68,8 +69,8 @@ class PanelWebhookService: # This user has tribute payments, auto-renew for the same duration logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months") - # Extend subscription by the last payment duration - new_end_date = datetime.now(timezone.utc) + timedelta(days=last_tribute_duration * 30) + # Extend subscription by the last payment duration (calendar months) + new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration) await subscription_dal.update_subscription( session, diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 82cb9a0..9e518c8 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -50,7 +50,9 @@ class ReferralService: # If configured to apply referral bonuses only once per invited user, # check if the referee already has succeeded payments. - if self.settings.REFERRAL_ONE_BONUS_PER_REFEREE: + # Use getattr with a safe default (True) to avoid AttributeError if + # running with an older settings schema. + if getattr(self.settings, "REFERRAL_ONE_BONUS_PER_REFEREE", True): try: succeeded_count = await payment_dal.count_user_succeeded_payments( session, referee_user_id, exclude_payment_id=current_payment_db_id diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index f9557c2..b349e39 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -6,6 +6,7 @@ from aiogram import Bot from bot.middlewares.i18n import JsonI18n from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal +from bot.utils.date_utils import add_months from db.models import User, Subscription from config.settings import Settings @@ -232,23 +233,10 @@ class SubscriptionService: "panel_user_uuid": actual_panel_uuid_from_api } - if ( - actual_panel_username_from_api - and actual_panel_username_from_api - != panel_username_on_panel_standard - and ( - db_user.username is None - or db_user.username != actual_panel_username_from_api - ) - ): - update_data_for_local_user["username"] = ( - actual_panel_username_from_api - ) - + # Do not overwrite Telegram username with panel username. + # Only update the local linkage to panel UUID here. await user_dal.update_user(session, user_id, update_data_for_local_user) db_user.panel_user_uuid = actual_panel_uuid_from_api - if "username" in update_data_for_local_user: - db_user.username = update_data_for_local_user["username"] panel_user_created_or_linked_now = True current_local_panel_uuid = actual_panel_uuid_from_api else: @@ -437,7 +425,9 @@ class SubscriptionService: ): start_date = current_active_sub.end_date - duration_days_total = months * 30 + # base duration by months + end_after_months = add_months(start_date, months) + duration_days_total = (end_after_months - start_date).days applied_promo_bonus_days = 0 if promo_code_id_from_payment: diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 512ba75..421b083 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1 +1,263 @@ -# Bot utilities package \ No newline at end of file +# Bot utilities package + +from dataclasses import dataclass +from typing import Optional, Dict, Any +from aiogram import types + + +@dataclass +class MessageContent: + """Класс для хранения информации о контенте сообщения""" + content_type: str + file_id: Optional[str] = None + text: Optional[str] = None + + +# Словари поддерживаемых параметров для каждого типа сообщения +SUPPORTED_PARAMS = { + "text": {"parse_mode", "entities", "disable_web_page_preview", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"}, + "photo": {"caption", "parse_mode", "caption_entities", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"}, + "video": {"duration", "width", "height", "thumbnail", "caption", "parse_mode", "caption_entities", "supports_streaming", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"}, + "animation": {"duration", "width", "height", "thumbnail", "caption", "parse_mode", "caption_entities", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"}, + "document": {"thumbnail", "caption", "parse_mode", "caption_entities", "disable_content_type_detection", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"}, + "audio": {"caption", "parse_mode", "caption_entities", "duration", "performer", "title", "thumbnail", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"}, + "voice": {"caption", "parse_mode", "caption_entities", "duration", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"}, + "sticker": {"disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"}, + "video_note": {"duration", "length", "thumbnail", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"}, +} + + +def filter_kwargs(content_type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Фильтрует kwargs, оставляя только поддерживаемые параметры для данного типа сообщения""" + supported = SUPPORTED_PARAMS.get(content_type, set()) + return {k: v for k, v in kwargs.items() if k in supported} + + +def get_message_content(message: types.Message) -> MessageContent: + """ + Определяет тип контента сообщения и возвращает его данные. + Использует match/case вместо длинных if-elif цепочек. + """ + text = (message.text or message.caption or "").strip() + + # Проверяем наличие медиа-контента + media_content = None + if message.photo: + media_content = ("photo", message.photo[-1].file_id) + elif message.video: + media_content = ("video", message.video.file_id) + elif message.animation: + media_content = ("animation", message.animation.file_id) + elif message.document: + media_content = ("document", message.document.file_id) + elif message.audio: + media_content = ("audio", message.audio.file_id) + elif message.voice: + media_content = ("voice", message.voice.file_id) + elif message.sticker: + media_content = ("sticker", message.sticker.file_id) + elif message.video_note: + media_content = ("video_note", message.video_note.file_id) + + # Используем match/case для определения типа контента + match media_content: + case (content_type, file_id): + return MessageContent(content_type=content_type, file_id=file_id, text=text) + case None: + return MessageContent(content_type="text", text=text) + case _: + return MessageContent(content_type="text", text=text) + + +async def send_message_by_type(bot, chat_id: int, content: MessageContent, **kwargs) -> None: + """ + Отправляет сообщение указанного типа. + Использует match/case вместо длинных if-elif цепочек. + Автоматически фильтрует неподдерживаемые параметры. + """ + # Фильтруем kwargs для данного типа сообщения + filtered_kwargs = filter_kwargs(content.content_type, kwargs) + + match content.content_type: + case "text": + await bot.send_message( + chat_id=chat_id, + text=content.text, + **filtered_kwargs + ) + case "photo": + await bot.send_photo( + chat_id=chat_id, + photo=content.file_id, + caption=content.text or None, + **filtered_kwargs + ) + case "video": + await bot.send_video( + chat_id=chat_id, + video=content.file_id, + caption=content.text or None, + **filtered_kwargs + ) + case "animation": + await bot.send_animation( + chat_id=chat_id, + animation=content.file_id, + caption=content.text or None, + **filtered_kwargs + ) + case "document": + await bot.send_document( + chat_id=chat_id, + document=content.file_id, + caption=content.text or None, + **filtered_kwargs + ) + case "audio": + await bot.send_audio( + chat_id=chat_id, + audio=content.file_id, + caption=content.text or None, + **filtered_kwargs + ) + case "voice": + await bot.send_voice( + chat_id=chat_id, + voice=content.file_id, + caption=content.text or None, + **filtered_kwargs + ) + case "sticker": + await bot.send_sticker( + chat_id=chat_id, + sticker=content.file_id, + **filtered_kwargs + ) + case "video_note": + await bot.send_video_note( + chat_id=chat_id, + video_note=content.file_id, + **filtered_kwargs + ) + case _: + # Fallback для неизвестных типов - отправляем как текст + text_kwargs = filter_kwargs("text", kwargs) + await bot.send_message( + chat_id=chat_id, + text=content.text or "Unknown content type", + **text_kwargs + ) + + +async def send_message_via_queue(queue_manager, uid: int, content: MessageContent, **kwargs) -> None: + """ + Отправляет сообщение через очередь в зависимости от типа контента. + Использует match/case вместо длинных if-elif цепочек. + Автоматически фильтрует неподдерживаемые параметры. + """ + # Фильтруем kwargs для данного типа сообщения + filtered_kwargs = filter_kwargs(content.content_type, kwargs) + + match content.content_type: + case "text": + await queue_manager.send_message( + chat_id=uid, text=content.text, **filtered_kwargs + ) + case "photo": + await queue_manager.send_photo( + chat_id=uid, photo=content.file_id, caption=content.text or None, **filtered_kwargs + ) + case "video": + await queue_manager.send_video( + chat_id=uid, video=content.file_id, caption=content.text or None, **filtered_kwargs + ) + case "animation": + await queue_manager.send_animation( + chat_id=uid, animation=content.file_id, caption=content.text or None, **filtered_kwargs + ) + case "document": + await queue_manager.send_document( + chat_id=uid, document=content.file_id, caption=content.text or None, **filtered_kwargs + ) + case "audio": + await queue_manager.send_audio( + chat_id=uid, audio=content.file_id, caption=content.text or None, **filtered_kwargs + ) + case "voice": + await queue_manager.send_voice( + chat_id=uid, voice=content.file_id, caption=content.text or None, **filtered_kwargs + ) + case "sticker": + await queue_manager.send_sticker( + chat_id=uid, sticker=content.file_id, **filtered_kwargs + ) + case "video_note": + await queue_manager.send_video_note( + chat_id=uid, video_note=content.file_id, **filtered_kwargs + ) + case _: + # Fallback для неизвестных типов - отправляем как текст + text_kwargs = filter_kwargs("text", kwargs) + await queue_manager.send_message( + chat_id=uid, text=content.text or "Unknown content type", **text_kwargs + ) + + +async def send_direct_message(bot, chat_id: int, content: MessageContent, extra_text: str = "", **kwargs) -> None: + """ + Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note. + Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption. + Автоматически фильтрует неподдерживаемые параметры. + """ + match content.content_type: + case "sticker": + # Отправляем стикер с отфильтрованными параметрами + sticker_kwargs = filter_kwargs("sticker", kwargs) + await bot.send_sticker( + chat_id=chat_id, + sticker=content.file_id, + **sticker_kwargs + ) + # Если есть текст с подписью, отправляем отдельно + if content.text or extra_text: + text_to_send = (content.text + extra_text) if content.text else extra_text + text_kwargs = filter_kwargs("text", kwargs) + await bot.send_message( + chat_id, + text_to_send, + **text_kwargs + ) + case "video_note": + # Отправляем видео-заметку с отфильтрованными параметрами + video_note_kwargs = filter_kwargs("video_note", kwargs) + await bot.send_video_note( + chat_id=chat_id, + video_note=content.file_id, + **video_note_kwargs + ) + # Если есть текст с подписью, отправляем отдельно + if content.text or extra_text: + text_to_send = (content.text + extra_text) if content.text else extra_text + text_kwargs = filter_kwargs("text", kwargs) + await bot.send_message( + chat_id, + text_to_send, + **text_kwargs + ) + case "text": + # Для текста объединяем с extra_text + final_text = (content.text + extra_text) if content.text else extra_text + text_kwargs = filter_kwargs("text", kwargs) + await bot.send_message( + chat_id=chat_id, + text=final_text, + **text_kwargs + ) + case _: + # Для остальных типов медиа используем caption + final_caption = (content.text + extra_text) if content.text else None + await send_message_by_type( + bot, chat_id, + MessageContent(content.content_type, content.file_id, final_caption), + **kwargs + ) \ No newline at end of file diff --git a/bot/utils/date_utils.py b/bot/utils/date_utils.py new file mode 100644 index 0000000..b9e2fdf --- /dev/null +++ b/bot/utils/date_utils.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta + + +def add_months(base_dt: datetime, months_to_add: int) -> datetime: + """Add calendar months to a datetime, clamping the day to the month's length. + + Preserves tzinfo from base_dt. + """ + year = base_dt.year + month = base_dt.month + months_to_add + day = base_dt.day + + # Normalize year and month + year += (month - 1) // 12 + month = ((month - 1) % 12) + 1 + + # Determine last day of target month by rolling to next month's first day and subtracting 1 day + if month == 12: + next_month_first = datetime(year + 1, 1, 1, tzinfo=base_dt.tzinfo) + else: + next_month_first = datetime(year, month + 1, 1, tzinfo=base_dt.tzinfo) + last_day = (next_month_first - timedelta(days=1)).day + + clamped_day = min(day, last_day) + return base_dt.replace(year=year, month=month, day=clamped_day) + + diff --git a/bot/utils/message_queue.py b/bot/utils/message_queue.py index a0704b8..40e3c0b 100644 --- a/bot/utils/message_queue.py +++ b/bot/utils/message_queue.py @@ -151,6 +151,76 @@ class MessageQueueManager: ) await queue.add_message(message) + async def send_photo(self, chat_id: int, **kwargs) -> None: + """Queue a send_photo call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_photo', + kwargs=kwargs + ) + await queue.add_message(message) + + async def send_video(self, chat_id: int, **kwargs) -> None: + """Queue a send_video call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_video', + kwargs=kwargs + ) + await queue.add_message(message) + + async def send_animation(self, chat_id: int, **kwargs) -> None: + """Queue a send_animation (GIF) call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_animation', + kwargs=kwargs + ) + await queue.add_message(message) + + async def send_audio(self, chat_id: int, **kwargs) -> None: + """Queue a send_audio call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_audio', + kwargs=kwargs + ) + await queue.add_message(message) + + async def send_voice(self, chat_id: int, **kwargs) -> None: + """Queue a send_voice call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_voice', + kwargs=kwargs + ) + await queue.add_message(message) + + async def send_sticker(self, chat_id: int, **kwargs) -> None: + """Queue a send_sticker call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_sticker', + kwargs=kwargs + ) + await queue.add_message(message) + + async def send_video_note(self, chat_id: int, **kwargs) -> None: + """Queue a send_video_note call""" + queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue + message = QueuedMessage( + chat_id=chat_id, + method_name='send_video_note', + kwargs=kwargs + ) + await queue.add_message(message) + async def answer_callback_query(self, callback_query_id: str, **kwargs) -> None: """Send callback query answer immediately (not rate limited)""" await self.bot.answer_callback_query(callback_query_id, **kwargs) diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index 049ffc2..423945f 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload from sqlalchemy import update, delete, func, and_ -from datetime import datetime +from datetime import datetime, timezone +from sqlalchemy.dialects.postgresql import insert as pg_insert from ..models import User, Subscription @@ -33,19 +34,41 @@ async def get_user_by_panel_uuid( ## Removed unused generic get_user helper to keep DAL explicit and simple -async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User: +async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple[User, bool]: + """Create a user if not exists in a race-safe way. + + Returns a tuple of (user, created_flag). + """ if "registration_date" not in user_data: - user_data["registration_date"] = datetime.now() + user_data["registration_date"] = datetime.now(timezone.utc) - new_user = User(**user_data) - session.add(new_user) - await session.flush() - await session.refresh(new_user) - logging.info( - f"New user {new_user.user_id} created in DAL. Referred by: {new_user.referred_by_id or 'N/A'}." + # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts + stmt = ( + pg_insert(User) + .values(**user_data) + .on_conflict_do_nothing(index_elements=[User.user_id]) + .returning(User.user_id) ) - return new_user + + result = await session.execute(stmt) + inserted_row = result.first() + created = inserted_row is not None + + # Fetch the user (inserted just now or pre-existing) + user_id: int = user_data["user_id"] + user = await get_user_by_id(session, user_id) + + if created and user is not None: + logging.info( + f"New user {user.user_id} created in DAL. Referred by: {user.referred_by_id or 'N/A'}." + ) + elif user is not None: + logging.info( + f"User {user.user_id} already exists in DAL. Proceeding without creation." + ) + + return user, created async def update_user( diff --git a/locales/en.json b/locales/en.json index 0a04e97..1644f35 100644 --- a/locales/en.json +++ b/locales/en.json @@ -155,6 +155,7 @@ "admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):", "admin_broadcast_confirm_prompt": "You are about to send the following message:\n\n{message_preview}\n\nConfirm sending?", + "admin_broadcast_confirm_prompt_short": "The message above will be sent. Confirm?", "broadcast_target_all_button": "👥 All", "broadcast_target_active_button": "✅ Active", "broadcast_target_inactive_button": "⌛ Inactive", diff --git a/locales/ru.json b/locales/ru.json index 64f9e70..b723756 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -155,6 +155,7 @@ "admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):", "admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение:\n\n{message_preview}\n\nПодтверждаете отправку?", + "admin_broadcast_confirm_prompt_short": "Сообщение выше будет отправлено. Подтвердить отправку?", "broadcast_target_all_button": "👥 Все", "broadcast_target_active_button": "✅ Активные", "broadcast_target_inactive_button": "⌛ Неактивные",