Merge pull request #77 from machka-pasla/dev

Update broadcast and some other small bugs 🪲
This commit is contained in:
Machka Pasla
2025-08-25 14:13:27 +03:00
committed by GitHub
14 changed files with 518 additions and 127 deletions
+33 -28
View File
@@ -19,6 +19,7 @@ from bot.keyboards.inline.admin_keyboards import (
) )
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.message_queue import get_queue_manager 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") 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) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Сохраняем в state исходный текст и entities # Определяем тип содержимого и сохраняем данные в state
text = (message.text or message.caption or "").strip()
entities = message.entities or message.caption_entities or [] 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")) await message.answer(_("admin_broadcast_error_no_message"))
return 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: try:
test_msg = await bot.send_message( await send_message_by_type(
bot,
chat_id=message.chat.id, chat_id=message.chat.id,
text=text, content=content,
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
disable_notification=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: except TelegramBadRequest as e:
await message.answer( await message.answer(
_( _(
@@ -110,13 +115,8 @@ async def process_broadcast_message_handler(
) )
return return
await state.update_data( # Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью
broadcast_text=text, confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
broadcast_entities=entities,
broadcast_target="all",
)
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=text)
await message.answer( await message.answer(
confirmation_prompt, confirmation_prompt,
@@ -148,10 +148,9 @@ async def change_broadcast_target_handler(
await state.update_data(broadcast_target=new_target) await state.update_data(broadcast_target=new_target)
user_fsm_data = await state.get_data() user_fsm_data = await state.get_data()
text = user_fsm_data.get("broadcast_text", "")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
confirmation_prompt = _( confirmation_prompt = _(
"admin_broadcast_confirm_prompt", message_preview=text "admin_broadcast_confirm_prompt_short"
) )
try: try:
await callback.message.edit_text( await callback.message.edit_text(
@@ -221,10 +220,15 @@ async def confirm_broadcast_callback_handler(
user_fsm_data = await state.get_data() user_fsm_data = await state.get_data()
if action == "send": if action == "send":
# Создаем объект контента из сохраненных данных
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") text=user_fsm_data.get("broadcast_text")
)
entities = user_fsm_data.get("broadcast_entities", []) 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 callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear() await state.clear()
await callback.answer( await callback.answer(
@@ -247,7 +251,7 @@ async def confirm_broadcast_callback_handler(
failed_count = 0 failed_count = 0
admin_user = callback.from_user admin_user = callback.from_user
logging.info( 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 # Get message queue manager
@@ -259,9 +263,10 @@ async def confirm_broadcast_callback_handler(
# Queue all messages for sending # Queue all messages for sending
for uid in user_ids: for uid in user_ids:
try: try:
await queue_manager.send_message( await send_message_via_queue(
chat_id=uid, queue_manager,
text=text, uid,
content,
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
@@ -275,7 +280,7 @@ async def confirm_broadcast_callback_handler(
"telegram_username": admin_user.username, "telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name, "telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_queued", "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, "is_admin_event": True,
"target_user_id": uid, "target_user_id": uid,
}, },
+35 -7
View File
@@ -1,6 +1,7 @@
import logging import logging
import re import re
from aiogram import Router, F, types, Bot from aiogram import Router, F, types, Bot
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.utils.markdown import hcode, hbold from aiogram.utils.markdown import hcode, hbold
from typing import Optional, Dict, Any 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.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils import get_message_content, send_direct_message
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
router = Router(name="admin_user_management_router") 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() 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, async def process_direct_message_handler(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict, settings: Settings, i18n_data: dict,
bot: Bot, session: AsyncSession): bot: Bot, session: AsyncSession):
@@ -597,8 +599,9 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
await state.clear() await state.clear()
return return
message_text = message.text.strip() # Determine content similar to broadcast
if len(message_text) > 4000: text = (message.text or message.caption or "").strip()
if len(text) > 4000:
await message.answer(_( await message.answer(_(
"admin_user_message_too_long", "admin_user_message_too_long",
default="❌ Сообщение слишком длинное (максимум 4000 символов)" default="❌ Сообщение слишком длинное (максимум 4000 символов)"
@@ -613,15 +616,40 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
await state.clear() await state.clear()
return return
# Prepare message with admin signature # Prepare admin signature and get content
admin_signature = _( admin_signature = _(
"admin_direct_message_signature", "admin_direct_message_signature",
default="\n\n---\n💬 Сообщение от администратора" default="\n\n---\n💬 Сообщение от администратора"
) )
full_message = message_text + admin_signature
# Send message to user content = get_message_content(message)
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 # Confirm to admin
await message.answer(_( await message.answer(_(
+2 -1
View File
@@ -165,8 +165,9 @@ async def start_command_handler(message: types.Message,
"registration_date": datetime.now(timezone.utc) "registration_date": datetime.now(timezone.utc)
} }
try: 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)
if created:
logging.info( logging.info(
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
) )
+14 -38
View File
@@ -107,10 +107,10 @@ async def on_startup_configured(dispatcher: Dispatcher):
"STARTUP: Skipped setting Telegram webhook due to security or configuration error." "STARTUP: Skipped setting Telegram webhook due to security or configuration error."
) )
else: else:
logging.info( logging.error(
"STARTUP: WEBHOOK_BASE_URL not set in environment. Running in polling mode and clearing any existing webhook." "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: if settings.SUBSCRIPTION_MINI_APP_URL:
try: try:
@@ -271,54 +271,30 @@ async def run_bot(settings_param: Settings):
await register_all_routers(dp, settings_param) await register_all_routers(dp, settings_param)
tg_webhook_base = settings_param.WEBHOOK_BASE_URL 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 ( # Webhook mode is now required - exit if not configured
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path) if not tg_webhook_base:
) logging.error("WEBHOOK_BASE_URL is required. Polling mode is disabled. Exiting.")
await dp.emit_shutdown()
telegram_uses_webhook_mode = bool(tg_webhook_base) raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
run_telegram_polling = not telegram_uses_webhook_mode
logging.info(f"--- Bot Run Mode Decision ---") logging.info(f"--- Bot Run Mode Decision ---")
logging.info( logging.info(f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Webhook Mode: ENABLED")
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: ENABLED (required for webhooks)")
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"--- End Bot Run Mode Decision ---") logging.info(f"--- End Bot Run Mode Decision ---")
web_app_runner = None web_app_runner = None
main_tasks = [] main_tasks = []
if should_run_aiohttp_server: # Only run AIOHTTP server for webhook mode
async def web_server_task(): async def web_server_task():
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) 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 Webhook mode with AIOHTTP server...")
logging.info("Starting bot in Telegram Polling mode...") logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
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]}"
)
try: try:
await asyncio.gather(*main_tasks) await asyncio.gather(*main_tasks)
+5 -1
View File
@@ -1,4 +1,4 @@
from aiogram import Router from aiogram import Router, F
from bot.handlers.user import user_router_aggregate from bot.handlers.user import user_router_aggregate
from bot.handlers import inline_mode from bot.handlers import inline_mode
@@ -10,6 +10,10 @@ from config.settings import Settings
def build_root_router(settings: Settings) -> Router: def build_root_router(settings: Settings) -> Router:
root = Router(name="root") 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 # Public routers
root.include_router(user_router_aggregate) root.include_router(user_router_aggregate)
root.include_router(inline_mode.router) root.include_router(inline_mode.router)
+4 -3
View File
@@ -11,6 +11,7 @@ from config.settings import Settings
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from db.dal import user_dal from db.dal import user_dal
from bot.utils.date_utils import add_months
EVENT_MAP = { EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"), "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. Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
""" """
from db.dal import subscription_dal, payment_dal from db.dal import subscription_dal, payment_dal
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone
try: try:
auto_renewed = False auto_renewed = False
@@ -68,8 +69,8 @@ class PanelWebhookService:
# This user has tribute payments, auto-renew for the same duration # 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") logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
# Extend subscription by the last payment duration # Extend subscription by the last payment duration (calendar months)
new_end_date = datetime.now(timezone.utc) + timedelta(days=last_tribute_duration * 30) new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
await subscription_dal.update_subscription( await subscription_dal.update_subscription(
session, session,
+3 -1
View File
@@ -50,7 +50,9 @@ class ReferralService:
# If configured to apply referral bonuses only once per invited user, # If configured to apply referral bonuses only once per invited user,
# check if the referee already has succeeded payments. # 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: try:
succeeded_count = await payment_dal.count_user_succeeded_payments( succeeded_count = await payment_dal.count_user_succeeded_payments(
session, referee_user_id, exclude_payment_id=current_payment_db_id session, referee_user_id, exclude_payment_id=current_payment_db_id
+6 -16
View File
@@ -6,6 +6,7 @@ from aiogram import Bot
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal 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 db.models import User, Subscription
from config.settings import Settings from config.settings import Settings
@@ -232,23 +233,10 @@ class SubscriptionService:
"panel_user_uuid": actual_panel_uuid_from_api "panel_user_uuid": actual_panel_uuid_from_api
} }
if ( # Do not overwrite Telegram username with panel username.
actual_panel_username_from_api # Only update the local linkage to panel UUID here.
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
)
await user_dal.update_user(session, user_id, update_data_for_local_user) await user_dal.update_user(session, user_id, update_data_for_local_user)
db_user.panel_user_uuid = actual_panel_uuid_from_api 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 panel_user_created_or_linked_now = True
current_local_panel_uuid = actual_panel_uuid_from_api current_local_panel_uuid = actual_panel_uuid_from_api
else: else:
@@ -437,7 +425,9 @@ class SubscriptionService:
): ):
start_date = current_active_sub.end_date 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 applied_promo_bonus_days = 0
if promo_code_id_from_payment: if promo_code_id_from_payment:
+262
View File
@@ -1 +1,263 @@
# Bot utilities package # 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
)
+27
View File
@@ -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)
+70
View File
@@ -151,6 +151,76 @@ class MessageQueueManager:
) )
await queue.add_message(message) 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: async def answer_callback_query(self, callback_query_id: str, **kwargs) -> None:
"""Send callback query answer immediately (not rate limited)""" """Send callback query answer immediately (not rate limited)"""
await self.bot.answer_callback_query(callback_query_id, **kwargs) await self.bot.answer_callback_query(callback_query_id, **kwargs)
+33 -10
View File
@@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_ 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 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 ## 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: 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) # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
session.add(new_user) stmt = (
await session.flush() pg_insert(User)
await session.refresh(new_user) .values(**user_data)
logging.info( .on_conflict_do_nothing(index_elements=[User.user_id])
f"New user {new_user.user_id} created in DAL. Referred by: {new_user.referred_by_id or 'N/A'}." .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( async def update_user(
+1
View File
@@ -155,6 +155,7 @@
"admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):", "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": "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_all_button": "👥 All",
"broadcast_target_active_button": "✅ Active", "broadcast_target_active_button": "✅ Active",
"broadcast_target_inactive_button": "⌛ Inactive", "broadcast_target_inactive_button": "⌛ Inactive",
+1
View File
@@ -155,6 +155,7 @@
"admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):", "admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение:\n\n{message_preview}\n\nПодтверждаете отправку?", "admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение:\n\n{message_preview}\n\nПодтверждаете отправку?",
"admin_broadcast_confirm_prompt_short": "Сообщение выше будет отправлено. Подтвердить отправку?",
"broadcast_target_all_button": "👥 Все", "broadcast_target_all_button": "👥 Все",
"broadcast_target_active_button": "✅ Активные", "broadcast_target_active_button": "✅ Активные",
"broadcast_target_inactive_button": "⌛ Неактивные", "broadcast_target_inactive_button": "⌛ Неактивные",