Compare commits

...
12 Commits
Author SHA1 Message Date
Machka PaslaandGitHub 5c74a08ed8 Merge pull request #78 from machka-pasla/dev
Implement user creation and synchronization enhancements in admin syn…
2025-08-26 18:14:40 +03:00
machka-pasla 14950fd559 Implement user creation and synchronization enhancements in admin sync handler
- Added functionality to create new users during synchronization if they are not found in the local database and have a valid Telegram ID.
- Introduced logging for newly created users to improve tracking and debugging.
- Enhanced synchronization statistics to include the count of newly created users, with localization support for both English and Russian.
- Updated the details of synchronization status to reflect additional statistics, improving clarity in admin reports.
2025-08-26 18:11:52 +03:00
Machka PaslaandGitHub 711b9a2487 Merge pull request #77 from machka-pasla/dev
Update broadcast and some other small bugs 🪲
2025-08-25 14:13:27 +03:00
machka-pasla 6e7eb6acfd Enhance message sending functionality to filter unsupported parameters
- Introduced a `SUPPORTED_PARAMS` dictionary to define valid parameters for each message type.
- Added a `filter_kwargs` utility function to filter out unsupported parameters based on the content type.
- Updated `send_message_by_type`, `send_message_via_queue`, and `send_direct_message` functions to utilize the new filtering logic, ensuring only valid parameters are passed during message sending.
- Improved handling for unknown content types by sending a default text message.
2025-08-25 13:49:31 +03:00
machka-pasla b42fae8772 Refactor message handling in broadcast and user management to utilize new utility functions
- Introduced `get_message_content` and `send_message_by_type` utility functions to streamline content type handling and message sending for various media types.
- Updated `process_broadcast_message_handler` and `process_direct_message_handler` to leverage these new functions, reducing code duplication and improving maintainability.
- Enhanced error handling for empty messages and improved message formatting with admin signatures.
2025-08-25 13:44:37 +03:00
machka-pasla f707662125 Refactor bot initialization and routing logic to enforce webhook mode requirement
- Updated the bot's startup logic to require a configured WEBHOOK_BASE_URL, exiting if not set, and logging appropriate error messages.
- Simplified the decision-making process for running the AIOHTTP server, ensuring it only runs in webhook mode.
- Enhanced the router configuration to filter updates for private chats, improving message handling security.
2025-08-25 13:24:30 +03:00
machka-pasla 60c6e0e961 Refactor subscription duration calculation in PanelWebhookService and SubscriptionService
- Introduced a utility function `add_months` to handle subscription duration calculations based on calendar months instead of a fixed 30-day period.
- Updated the auto-renewal logic in `PanelWebhookService` to use the new function for extending subscription end dates.
- Adjusted the duration calculation in `SubscriptionService` to derive the end date after a specified number of months, improving accuracy in subscription management.
2025-08-25 12:30:44 +03:00
machka-pasla b23b75b72e Refactor username update logic in SubscriptionService to prevent overwriting Telegram usernames
- Removed the conditional logic that updated the local user's username with the panel username, ensuring that the Telegram username remains unchanged.
- Added a comment to clarify the purpose of the update, focusing on maintaining the linkage to the panel UUID.
2025-08-25 12:24:30 +03:00
machka-pasla c69f02f7c0 Enhance direct message handling in user management to support multiple content types
- Updated the process_direct_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and send them accordingly to the target user.
- Added error handling for empty messages and invalid HTML content, improving user feedback.
- Included admin signature in messages, ensuring consistent formatting across different content types.
2025-08-20 17:12:40 +03:00
machka-pasla f22e359684 Refactor user creation logic in DAL to support race-safe inserts and return creation status
- Updated the create_user function to use PostgreSQL upsert for concurrent user creation, preventing IntegrityError.
- Modified the function to return a tuple containing the user object and a boolean indicating if the user was newly created.
- Adjusted the start_command_handler to log user registration only if a new user was created, improving logging clarity.
2025-08-20 15:49:12 +03:00
machka-pasla d2402fea77 Add preview message functionality for multiple content types in broadcast handler
- Implemented preview message sending for various content types (text, photo, video, animation, document, audio, voice, sticker, video_note) in the process_broadcast_message_handler.
- Added error handling for invalid HTML content in broadcast messages, improving user feedback and experience.
- Enhanced the confirmation prompt to provide a concise message preview without duplicating text.
2025-08-20 15:39:22 +03:00
machka-pasla 9b8ddb39da Enhance broadcast message handling to support multiple content types
- Updated the process_broadcast_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and store relevant data in the state.
- Implemented new methods in MessageQueueManager for queuing various media types, improving the flexibility of the broadcast system.
- Adjusted confirmation prompts to provide a concise message preview, enhancing user experience.
- Added localization for the new confirmation prompt in both English and Russian.
2025-08-20 15:30:06 +03:00
15 changed files with 577 additions and 143 deletions
+34 -29
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":
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", []) 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,
}, },
+47 -14
View File
@@ -31,6 +31,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
# Additional counters for detailed logging # Additional counters for detailed logging
users_without_telegram_id = 0 users_without_telegram_id = 0
users_not_found_in_db = 0 users_not_found_in_db = 0
users_created = 0
users_uuid_updated = 0 users_uuid_updated = 0
subscriptions_created = 0 subscriptions_created = 0
subscriptions_updated = 0 subscriptions_updated = 0
@@ -93,10 +94,33 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
if not existing_user: if not existing_user:
users_not_found_in_db += 1 users_not_found_in_db += 1
if telegram_id_from_panel: if telegram_id_from_panel:
logging.debug(f"Panel user with telegramId {telegram_id_from_panel} and UUID {panel_uuid} not found in local DB") # Create new user if they have telegram_id
try:
user_data = {
"user_id": telegram_id_from_panel,
"username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info
"language_code": "ru", # Default language
"panel_user_uuid": panel_uuid,
"is_banned": False,
"referred_by_id": None
}
new_user, was_created = await user_dal.create_user(session, user_data)
if was_created:
users_created += 1
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
existing_user = new_user
except Exception as e_create:
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
continue
else: else:
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB") logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
continue continue
# User found in local DB # User found in local DB
users_found_in_db += 1 users_found_in_db += 1
@@ -218,20 +242,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
# Update sync status # Update sync status
status = "completed_with_errors" if sync_errors else "completed" status = "completed_with_errors" if sync_errors else "completed"
details = (f"📊 Статистика синхронизации:\n" # Build additional stats
f"🔍 Проверено записей панели: {panel_records_checked}\n" default_lang = settings.DEFAULT_LANGUAGE
f"👥 Найдено пользователей в БД: {users_found_in_db}\n" additional_stats = ""
f"🔄 Пользователей обновлено: {users_updated}\n"
f"📋 Подписок синхронизировано: {subscriptions_synced_count}\n"
f" ├── Создано новых: {subscriptions_created}\n"
f" └── Обновлено существующих: {subscriptions_updated}")
if users_without_telegram_id > 0: if users_without_telegram_id > 0:
details += f"\n⚠️ Записей без telegramId: {users_without_telegram_id}" additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
if users_not_found_in_db > 0: if users_not_found_in_db > 0:
details += f"\n❌ Не найдено в БД: {users_not_found_in_db}" additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
if sync_errors: if sync_errors:
details += f"\n🚫 Ошибок: {len(sync_errors)}" additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
# Build full details using localization
details = i18n_instance.gettext(default_lang, "admin_sync_details",
panel_records_checked=panel_records_checked,
users_found_in_db=users_found_in_db,
users_created=users_created,
users_updated=users_updated,
subscriptions_synced_count=subscriptions_synced_count,
subscriptions_created=subscriptions_created,
subscriptions_updated=subscriptions_updated,
additional_stats=additional_stats
)
await panel_sync_dal.update_panel_sync_status( await panel_sync_dal.update_panel_sync_status(
session, status, details, panel_records_checked, subscriptions_synced_count session, status, details, panel_records_checked, subscriptions_synced_count
@@ -244,6 +275,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
logging.info(f" Users without telegramId: {users_without_telegram_id}") logging.info(f" Users without telegramId: {users_without_telegram_id}")
logging.info(f" Users not found in local DB: {users_not_found_in_db}") logging.info(f" Users not found in local DB: {users_not_found_in_db}")
logging.info(f" Users found in local DB: {users_found_in_db}") logging.info(f" Users found in local DB: {users_found_in_db}")
logging.info(f" Users created: {users_created}")
logging.info(f" Users with UUID updated: {users_uuid_updated}") logging.info(f" Users with UUID updated: {users_uuid_updated}")
logging.info(f" Users updated overall: {users_updated}") logging.info(f" Users updated overall: {users_updated}")
logging.info(f" Subscriptions total synced: {subscriptions_synced_count}") logging.info(f" Subscriptions total synced: {subscriptions_synced_count}")
@@ -256,6 +288,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
"details": details, "details": details,
"users_processed": panel_records_checked, "users_processed": panel_records_checked,
"users_synced": users_found_in_db, "users_synced": users_found_in_db,
"users_created": users_created,
"subs_synced": subscriptions_synced_count, "subs_synced": subscriptions_synced_count,
"errors": sync_errors "errors": sync_errors
} }
+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(_(
+17 -16
View File
@@ -165,24 +165,25 @@ 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)
logging.info( if created:
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." 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
) )
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: except Exception as e_create:
logging.error( logging.error(
+17 -41
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(
+7 -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",
@@ -517,5 +518,10 @@
"admin_financial_week_label": "This week", "admin_financial_week_label": "This week",
"admin_financial_month_label": "This month", "admin_financial_month_label": "This month",
"admin_financial_all_time_label": "All time", "admin_financial_all_time_label": "All time",
"admin_financial_payments_label": "payments" "admin_financial_payments_label": "payments",
"admin_sync_details": "📊 Synchronization Statistics:\n🔍 Panel records checked: {panel_records_checked}\n👥 Users found in DB: {users_found_in_db}\n✨ New users created: {users_created}\n🔄 Users updated: {users_updated}\n📋 Subscriptions synced: {subscriptions_synced_count}\n ├── Created new: {subscriptions_created}\n └── Updated existing: {subscriptions_updated}{additional_stats}",
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
"admin_sync_errors": "\n🚫 Errors: {count}"
} }
+7 -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": "⌛ Неактивные",
@@ -525,5 +526,10 @@
"admin_financial_week_label": "За неделю", "admin_financial_week_label": "За неделю",
"admin_financial_month_label": "За месяц", "admin_financial_month_label": "За месяц",
"admin_financial_all_time_label": "За все время", "admin_financial_all_time_label": "За все время",
"admin_financial_payments_label": "платежей" "admin_financial_payments_label": "платежей",
"admin_sync_details": "📊 Статистика синхронизации:\n🔍 Проверено записей панели: {panel_records_checked}\n👥 Найдено пользователей в БД: {users_found_in_db}\n✨ Создано новых пользователей: {users_created}\n🔄 Пользователей обновлено: {users_updated}\n📋 Подписок синхронизировано: {subscriptions_synced_count}\n ├── Создано новых: {subscriptions_created}\n └── Обновлено существующих: {subscriptions_updated}{additional_stats}",
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
"admin_sync_errors": "\n🚫 Ошибок: {count}"
} }