Refactor notification handling and streamline router registration
- Replaced legacy notification functions with a unified NotificationService for better maintainability and clarity. - Updated the main bot router registration to utilize a root router, simplifying the inclusion of user and admin routes. - Removed unused middleware and helper functions to enhance code cleanliness and focus on essential components. - Improved localization by adding new error messages for user interactions.
This commit is contained in:
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.notification_service import notify_admin_panel_sync
|
from bot.services.notification_service import NotificationService
|
||||||
|
|
||||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||||
|
|
||||||
@@ -287,8 +287,9 @@ async def sync_command_handler(
|
|||||||
|
|
||||||
# Send notification to log channel with proper thread handling
|
# Send notification to log channel with proper thread handling
|
||||||
try:
|
try:
|
||||||
await notify_admin_panel_sync(
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
bot, settings, i18n, status, details,
|
await notification_service.notify_panel_sync(
|
||||||
|
status, details,
|
||||||
sync_result.get("users_processed", 0),
|
sync_result.get("users_processed", 0),
|
||||||
sync_result.get("subs_synced", 0)
|
sync_result.get("subs_synced", 0)
|
||||||
)
|
)
|
||||||
@@ -301,8 +302,9 @@ async def sync_command_handler(
|
|||||||
|
|
||||||
# Send notification to log channel about failure
|
# Send notification to log channel about failure
|
||||||
try:
|
try:
|
||||||
await notify_admin_panel_sync(
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
bot, settings, i18n, "failed", str(e_sync_global), 0, 0
|
await notification_service.notify_panel_sync(
|
||||||
|
"failed", str(e_sync_global), 0, 0
|
||||||
)
|
)
|
||||||
except Exception as e_notification:
|
except Exception as e_notification:
|
||||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from datetime import datetime
|
|||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
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.services.notification_service import notify_admin_new_trial
|
from bot.services.notification_service import NotificationService
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
get_trial_confirmation_keyboard,
|
get_trial_confirmation_keyboard,
|
||||||
get_main_menu_inline_keyboard,
|
get_main_menu_inline_keyboard,
|
||||||
@@ -97,13 +97,8 @@ async def request_trial_confirmation_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send notification to admin about new trial
|
# Send notification to admin about new trial
|
||||||
await notify_admin_new_trial(
|
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||||
callback.bot,
|
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||||
settings,
|
|
||||||
i18n,
|
|
||||||
user_id,
|
|
||||||
end_date_obj,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
message_key_from_service = (
|
message_key_from_service = (
|
||||||
activation_result.get("message_key", "trial_activation_failed")
|
activation_result.get("message_key", "trial_activation_failed")
|
||||||
@@ -264,13 +259,8 @@ async def confirm_activate_trial_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||||
await notify_admin_new_trial(
|
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||||
callback.bot,
|
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||||
settings,
|
|
||||||
i18n,
|
|
||||||
user_id,
|
|
||||||
end_date_obj,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||||
|
|||||||
+6
-61
@@ -1,17 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.types import (
|
from aiogram.types import (MenuButtonDefault, MenuButtonWebApp, WebAppInfo, BotCommand)
|
||||||
Update,
|
|
||||||
MenuButtonDefault,
|
|
||||||
MenuButtonWebApp,
|
|
||||||
WebAppInfo,
|
|
||||||
BotCommand,
|
|
||||||
)
|
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.filters import CommandStart, Command
|
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
@@ -24,13 +17,11 @@ from config.settings import Settings
|
|||||||
from db.database_setup import init_db_connection
|
from db.database_setup import init_db_connection
|
||||||
|
|
||||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||||
|
from bot.middlewares.db_session import DBSessionMiddleware
|
||||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||||
|
|
||||||
from bot.handlers.user import user_router_aggregate
|
from bot.routers import build_root_router
|
||||||
from bot.handlers.admin import admin_router_aggregate
|
|
||||||
from bot.handlers import inline_mode
|
|
||||||
from bot.filters.admin_filter import AdminFilter
|
|
||||||
|
|
||||||
from bot.services.yookassa_service import YooKassaService
|
from bot.services.yookassa_service import YooKassaService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
@@ -46,54 +37,8 @@ from bot.handlers.admin.sync_admin import perform_sync
|
|||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
|
|
||||||
|
|
||||||
class DBSessionMiddleware(BaseMiddleware):
|
|
||||||
|
|
||||||
def __init__(self, async_session_factory: sessionmaker):
|
|
||||||
super().__init__()
|
|
||||||
self.async_session_factory = async_session_factory
|
|
||||||
|
|
||||||
async def __call__(
|
|
||||||
self,
|
|
||||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
|
||||||
event: Update,
|
|
||||||
data: Dict[str, Any],
|
|
||||||
) -> Any:
|
|
||||||
if self.async_session_factory is None:
|
|
||||||
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
|
||||||
raise RuntimeError(
|
|
||||||
"async_session_factory not provided to DBSessionMiddleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with self.async_session_factory() as session:
|
|
||||||
data["session"] = session
|
|
||||||
try:
|
|
||||||
result = await handler(event, data)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
logging.error(
|
|
||||||
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||||
dp.include_router(user_router_aggregate)
|
dp.include_router(build_root_router(settings))
|
||||||
|
|
||||||
# Add inline mode router (available for all users)
|
|
||||||
dp.include_router(inline_mode.router)
|
|
||||||
|
|
||||||
admin_main_router = Router(name="admin_main_filtered_router")
|
|
||||||
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
|
|
||||||
|
|
||||||
admin_main_router.message.filter(admin_filter_instance)
|
|
||||||
admin_main_router.callback_query.filter(admin_filter_instance)
|
|
||||||
|
|
||||||
admin_main_router.include_router(admin_router_aggregate)
|
|
||||||
|
|
||||||
dp.include_router(admin_main_router)
|
|
||||||
logging.info("All application routers registered.")
|
logging.info("All application routers registered.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Callable, Dict, Any, Awaitable
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Update
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
class DBSessionMiddleware(BaseMiddleware):
|
||||||
|
|
||||||
|
def __init__(self, async_session_factory: sessionmaker):
|
||||||
|
super().__init__()
|
||||||
|
self.async_session_factory = async_session_factory
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Update,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
if self.async_session_factory is None:
|
||||||
|
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
||||||
|
raise RuntimeError(
|
||||||
|
"async_session_factory not provided to DBSessionMiddleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self.async_session_factory() as session:
|
||||||
|
data["session"] = session
|
||||||
|
try:
|
||||||
|
result = await handler(event, data)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from bot.handlers.user import user_router_aggregate
|
||||||
|
from bot.handlers import inline_mode
|
||||||
|
from bot.handlers.admin import admin_router_aggregate
|
||||||
|
from bot.filters.admin_filter import AdminFilter
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_root_router(settings: Settings) -> Router:
|
||||||
|
root = Router(name="root")
|
||||||
|
|
||||||
|
# Public routers
|
||||||
|
root.include_router(user_router_aggregate)
|
||||||
|
root.include_router(inline_mode.router)
|
||||||
|
|
||||||
|
# Admin routers behind filter
|
||||||
|
admin_main_router = Router(name="admin_main_filtered_router")
|
||||||
|
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
|
||||||
|
admin_main_router.message.filter(admin_filter_instance)
|
||||||
|
admin_main_router.callback_query.filter(admin_filter_instance)
|
||||||
|
admin_main_router.include_router(admin_router_aggregate)
|
||||||
|
root.include_router(admin_main_router)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
@@ -295,51 +295,4 @@ class NotificationService:
|
|||||||
if to_admins:
|
if to_admins:
|
||||||
await self._send_to_admins(message)
|
await self._send_to_admins(message)
|
||||||
|
|
||||||
|
# Removed legacy helper functions that duplicated NotificationService API
|
||||||
# Legacy functions for backward compatibility
|
|
||||||
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
|
|
||||||
message_key: str, parse_mode: str | None = None,
|
|
||||||
**kwargs) -> None:
|
|
||||||
if not settings.ADMIN_IDS:
|
|
||||||
return
|
|
||||||
admin_lang = settings.DEFAULT_LANGUAGE
|
|
||||||
msg = i18n.gettext(admin_lang, message_key, **kwargs)
|
|
||||||
for admin_id in settings.ADMIN_IDS:
|
|
||||||
try:
|
|
||||||
await bot.send_message(admin_id, msg, parse_mode=parse_mode)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to send admin notification to {admin_id}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
|
|
||||||
user_id: int, end_date: datetime) -> None:
|
|
||||||
"""Send notification to admins about new trial activation (legacy)"""
|
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
|
||||||
await notification_service.notify_trial_activation(user_id, end_date)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_admin_promo_activation(bot: Bot, settings: Settings,
|
|
||||||
i18n: JsonI18n, user_id: int,
|
|
||||||
code: str,
|
|
||||||
bonus_days: int) -> None:
|
|
||||||
await notify_admins(
|
|
||||||
bot,
|
|
||||||
settings,
|
|
||||||
i18n,
|
|
||||||
"admin_promo_activation_notification",
|
|
||||||
user_id=user_id,
|
|
||||||
code=code,
|
|
||||||
bonus_days=bonus_days,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_admin_panel_sync(bot: Bot, settings: Settings,
|
|
||||||
i18n: JsonI18n, status: str,
|
|
||||||
details: str, users_processed: int,
|
|
||||||
subs_synced: int) -> None:
|
|
||||||
"""Send notification to admins about panel sync (legacy)"""
|
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
|
||||||
await notification_service.notify_panel_sync(status, details, users_processed, subs_synced)
|
|
||||||
@@ -34,10 +34,7 @@ class SubscriptionService:
|
|||||||
else self.settings.DEFAULT_LANGUAGE
|
else self.settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
|
|
||||||
async def has_had_any_subscription(
|
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||||
self, session: AsyncSession, user_id: int
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||||
|
|
||||||
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
|
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
|
||||||
@@ -348,19 +345,12 @@ class SubscriptionService:
|
|||||||
"message_key": "trial_activation_failed_db",
|
"message_key": "trial_activation_failed_db",
|
||||||
}
|
}
|
||||||
|
|
||||||
panel_update_payload: Dict[str, Any] = {
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
"uuid": panel_user_uuid,
|
panel_user_uuid=panel_user_uuid,
|
||||||
"expireAt": end_date.isoformat(timespec="milliseconds").replace(
|
expire_at=end_date,
|
||||||
"+00:00", "Z"
|
status="ACTIVE",
|
||||||
),
|
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||||
"status": "ACTIVE",
|
)
|
||||||
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
|
|
||||||
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
|
|
||||||
}
|
|
||||||
if self.settings.parsed_user_squad_uuids:
|
|
||||||
panel_update_payload["activeInternalSquads"] = (
|
|
||||||
self.settings.parsed_user_squad_uuids
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||||
panel_user_uuid, panel_update_payload
|
panel_user_uuid, panel_update_payload
|
||||||
@@ -495,19 +485,12 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
panel_update_payload = {
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
"uuid": panel_user_uuid,
|
panel_user_uuid=panel_user_uuid,
|
||||||
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace(
|
expire_at=final_end_date,
|
||||||
"+00:00", "Z"
|
status="ACTIVE",
|
||||||
),
|
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||||
"status": "ACTIVE",
|
)
|
||||||
"trafficLimitBytes": self.settings.user_traffic_limit_bytes,
|
|
||||||
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
|
|
||||||
}
|
|
||||||
if self.settings.parsed_user_squad_uuids:
|
|
||||||
panel_update_payload["activeInternalSquads"] = (
|
|
||||||
self.settings.parsed_user_squad_uuids
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||||
panel_user_uuid, panel_update_payload
|
panel_user_uuid, panel_update_payload
|
||||||
@@ -598,17 +581,13 @@ class SubscriptionService:
|
|||||||
|
|
||||||
if updated_sub_model:
|
if updated_sub_model:
|
||||||
# Prepare panel update payload
|
# Prepare panel update payload
|
||||||
panel_update_payload = {
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
"expireAt": new_end_date_obj.isoformat(
|
expire_at=new_end_date_obj,
|
||||||
timespec="milliseconds"
|
traffic_limit_bytes=(
|
||||||
).replace("+00:00", "Z")
|
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
|
||||||
}
|
),
|
||||||
|
include_uuid=False,
|
||||||
# For promo code activations, remove traffic limit
|
)
|
||||||
if "promo code" in reason.lower():
|
|
||||||
panel_update_payload["trafficLimitBytes"] = self.settings.user_traffic_limit_bytes
|
|
||||||
panel_update_payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
|
||||||
logging.info(f"Updating traffic limit for user {user_id} to {self.settings.user_traffic_limit_bytes} bytes due to promo code activation")
|
|
||||||
|
|
||||||
panel_update_success = (
|
panel_update_success = (
|
||||||
await self.panel_service.update_user_details_on_panel(
|
await self.panel_service.update_user_details_on_panel(
|
||||||
@@ -775,3 +754,27 @@ class SubscriptionService:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time."
|
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Helpers
|
||||||
|
def _build_panel_update_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
panel_user_uuid: Optional[str] = None,
|
||||||
|
expire_at: Optional[datetime] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
traffic_limit_bytes: Optional[int] = None,
|
||||||
|
include_uuid: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if include_uuid and panel_user_uuid:
|
||||||
|
payload["uuid"] = panel_user_uuid
|
||||||
|
if expire_at is not None:
|
||||||
|
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
if status is not None:
|
||||||
|
payload["status"] = status
|
||||||
|
if traffic_limit_bytes is not None:
|
||||||
|
payload["trafficLimitBytes"] = traffic_limit_bytes
|
||||||
|
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
||||||
|
if self.settings.parsed_user_squad_uuids:
|
||||||
|
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
||||||
|
return payload
|
||||||
|
|||||||
+52
-108
@@ -39,11 +39,16 @@ def convert_period_to_months(period: Optional[str]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class TributeService:
|
class TributeService:
|
||||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
|
def __init__(
|
||||||
async_session_factory: sessionmaker,
|
self,
|
||||||
panel_service: PanelApiService,
|
bot: Bot,
|
||||||
subscription_service: SubscriptionService,
|
settings: Settings,
|
||||||
referral_service: ReferralService):
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
referral_service: ReferralService,
|
||||||
|
):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
@@ -52,8 +57,7 @@ class TributeService:
|
|||||||
self.subscription_service = subscription_service
|
self.subscription_service = subscription_service
|
||||||
self.referral_service = referral_service
|
self.referral_service = referral_service
|
||||||
|
|
||||||
async def handle_webhook(self, raw_body: bytes,
|
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||||
signature_header: Optional[str]) -> web.Response:
|
|
||||||
settings = self.settings
|
settings = self.settings
|
||||||
bot = self.bot
|
bot = self.bot
|
||||||
i18n = self.i18n
|
i18n = self.i18n
|
||||||
@@ -79,96 +83,53 @@ class TributeService:
|
|||||||
json.dumps(payload, ensure_ascii=False),
|
json.dumps(payload, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
event_name = payload.get('name')
|
# Tribute webhook spec: only two events are sent
|
||||||
data = payload.get('payload', {})
|
# name: new_subscription | cancelled_subscription
|
||||||
user_id = data.get('telegram_user_id')
|
event_name = payload.get("name")
|
||||||
price_val = (
|
data = payload.get("payload", {})
|
||||||
data.get('amount')
|
|
||||||
or data.get('amount_paid')
|
|
||||||
or data.get('price')
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user_id or price_val is None:
|
# Mandatory routing fields
|
||||||
return web.Response(status=200, text="ok_missing_fields")
|
user_id = data.get("telegram_user_id")
|
||||||
|
if not user_id:
|
||||||
|
return web.Response(status=400, text="missing_telegram_user_id")
|
||||||
|
|
||||||
period_val = data.get('period')
|
period_val = data.get("period")
|
||||||
months = convert_period_to_months(period_val)
|
months = convert_period_to_months(period_val)
|
||||||
price_rub = price_val / 100
|
|
||||||
|
# Price/amount from spec is integer cents in currency; we store float in Payment
|
||||||
|
amount_value = data.get("amount") or data.get("price")
|
||||||
|
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||||
|
amount_float = float(amount_value) if amount_value is not None else 0.0
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
# Normalize provider payment identifier to be unique per successful charge
|
if event_name == "new_subscription":
|
||||||
# Prefer true payment/transaction identifiers over subscription id
|
# Build a stable provider payment id from subscription and timestamps
|
||||||
provider_payment_id = (
|
provider_payment_id = str(data.get("subscription_id"))
|
||||||
data.get('payment_id')
|
# Idempotent ensure payment
|
||||||
or data.get('invoice_id')
|
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
||||||
or data.get('order_id')
|
session,
|
||||||
or data.get('transaction_id')
|
user_id=int(user_id),
|
||||||
or data.get('charge_id')
|
amount=amount_float,
|
||||||
or data.get('subscription_payment_id')
|
currency=currency,
|
||||||
)
|
months=months,
|
||||||
if provider_payment_id is None:
|
description="Tribute subscription",
|
||||||
# Fallback to subscription_id which may be stable across renewals
|
provider="tribute",
|
||||||
# To avoid deduplicating different renewals under same subscription,
|
provider_payment_id=provider_payment_id,
|
||||||
# append a timestamp if available
|
|
||||||
base_sub_id = data.get('subscription_id')
|
|
||||||
paid_at = (
|
|
||||||
data.get('paid_at')
|
|
||||||
or data.get('created_at')
|
|
||||||
or payload.get('timestamp')
|
|
||||||
or payload.get('id')
|
|
||||||
)
|
)
|
||||||
if base_sub_id is not None and paid_at is not None:
|
|
||||||
provider_payment_id = f"{base_sub_id}:{paid_at}"
|
|
||||||
elif base_sub_id is not None:
|
|
||||||
provider_payment_id = str(base_sub_id)
|
|
||||||
else:
|
|
||||||
provider_payment_id = str(provider_payment_id)
|
|
||||||
|
|
||||||
# Consider multiple Tribute events as successful charge events
|
|
||||||
success_events = {
|
|
||||||
'new_subscription',
|
|
||||||
'payment_succeeded',
|
|
||||||
'subscription_renewed',
|
|
||||||
'subscription_payment_succeeded',
|
|
||||||
'invoice_paid',
|
|
||||||
}
|
|
||||||
|
|
||||||
if event_name in success_events:
|
|
||||||
existing_payment = await payment_dal.get_payment_by_provider_payment_id(
|
|
||||||
session, provider_payment_id)
|
|
||||||
if existing_payment:
|
|
||||||
logging.info(
|
|
||||||
"Duplicate Tribute payment webhook ignored for provider_payment_id %s",
|
|
||||||
provider_payment_id,
|
|
||||||
)
|
|
||||||
payment_record = existing_payment
|
|
||||||
else:
|
|
||||||
payment_record = await payment_dal.create_payment_record(
|
|
||||||
session,
|
|
||||||
{
|
|
||||||
'user_id': user_id,
|
|
||||||
'amount': float(price_rub),
|
|
||||||
'currency': 'RUB',
|
|
||||||
'status': 'succeeded',
|
|
||||||
'description': 'Tribute subscription',
|
|
||||||
'subscription_duration_months': months,
|
|
||||||
'provider_payment_id': provider_payment_id,
|
|
||||||
'provider': 'tribute',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
activation_details = await subscription_service.activate_subscription(
|
activation_details = await subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
user_id,
|
int(user_id),
|
||||||
months,
|
months,
|
||||||
float(price_rub),
|
float(amount_float),
|
||||||
payment_record.payment_id,
|
payment_record.payment_id,
|
||||||
provider='tribute',
|
provider="tribute",
|
||||||
)
|
)
|
||||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||||
session, user_id, months)
|
session, int(user_id), months)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, int(user_id))
|
||||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||||
|
|
||||||
@@ -213,7 +174,7 @@ class TributeService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
user_id,
|
int(user_id),
|
||||||
success_msg,
|
success_msg,
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
@@ -226,21 +187,19 @@ class TributeService:
|
|||||||
# Send notification about payment
|
# Send notification about payment
|
||||||
try:
|
try:
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, int(user_id))
|
||||||
await notification_service.notify_payment_received(
|
await notification_service.notify_payment_received(
|
||||||
user_id=user_id,
|
user_id=int(user_id),
|
||||||
amount=float(price_rub),
|
amount=float(amount_float),
|
||||||
currency="RUB",
|
currency=currency,
|
||||||
months=months,
|
months=months,
|
||||||
payment_provider="tribute",
|
payment_provider="tribute",
|
||||||
username=user.username if user else None
|
username=user.username if user else None
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send tribute payment notification: {e}")
|
logging.error(f"Failed to send tribute payment notification: {e}")
|
||||||
|
elif event_name == "cancelled_subscription":
|
||||||
elif event_name == 'subscription_cancelled':
|
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
|
||||||
# Handle tribute subscription cancellation
|
|
||||||
await self._handle_tribute_cancellation(session, user_id, bot, i18n)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -254,22 +213,7 @@ class TributeService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Set all user's subscriptions to expire in 1 day (grace period)
|
# Set all user's subscriptions to expire in 1 day (grace period)
|
||||||
grace_end_date = datetime.now(timezone.utc) + timedelta(days=1)
|
await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, grace_days=1)
|
||||||
|
|
||||||
# Get all active subscriptions for the user
|
|
||||||
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
|
||||||
|
|
||||||
for sub in user_subs:
|
|
||||||
await subscription_dal.update_subscription(
|
|
||||||
session,
|
|
||||||
sub.subscription_id,
|
|
||||||
{
|
|
||||||
'end_date': grace_end_date,
|
|
||||||
'status_from_panel': 'CANCELLED',
|
|
||||||
'skip_notifications': True # Skip future notifications for cancelled subs
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Send notification about cancellation if enabled
|
# Send notification about cancellation if enabled
|
||||||
@@ -292,7 +236,7 @@ class TributeService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
user_id,
|
int(user_id),
|
||||||
cancellation_msg,
|
cancellation_msg,
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ async def get_payment_by_provider_payment_id(
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_payment_with_provider_id(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
amount: float,
|
||||||
|
currency: str,
|
||||||
|
months: int,
|
||||||
|
description: str,
|
||||||
|
provider: str,
|
||||||
|
provider_payment_id: str) -> Payment:
|
||||||
|
"""Idempotently create a payment record for a provider event.
|
||||||
|
|
||||||
|
If a payment with the same provider_payment_id already exists, returns it.
|
||||||
|
Otherwise creates a new succeeded payment with provided data.
|
||||||
|
"""
|
||||||
|
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
payment_payload: Dict[str, Any] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": float(amount),
|
||||||
|
"currency": currency,
|
||||||
|
"status": "succeeded",
|
||||||
|
"description": description,
|
||||||
|
"subscription_duration_months": months,
|
||||||
|
"provider_payment_id": provider_payment_id,
|
||||||
|
"provider": provider,
|
||||||
|
}
|
||||||
|
return await create_payment_record(session, payment_payload)
|
||||||
|
|
||||||
|
|
||||||
async def get_payment_by_db_id(session: AsyncSession,
|
async def get_payment_by_db_id(session: AsyncSession,
|
||||||
payment_db_id: int) -> Optional[Payment]:
|
payment_db_id: int) -> Optional[Payment]:
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,29 @@ async def update_subscription(
|
|||||||
return sub
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_subscriptions_cancelled_with_grace(
|
||||||
|
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
|
||||||
|
"""Mark all active user subscriptions as cancelled with a short grace period.
|
||||||
|
|
||||||
|
Sets end_date to now + grace_days, status_from_panel to 'CANCELLED', and
|
||||||
|
skip future notifications to reduce noise after cancellation.
|
||||||
|
Returns number of updated rows.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||||
|
stmt = (
|
||||||
|
update(Subscription)
|
||||||
|
.where(Subscription.user_id == user_id, Subscription.is_active == True)
|
||||||
|
.values(
|
||||||
|
end_date=grace_end,
|
||||||
|
status_from_panel="CANCELLED",
|
||||||
|
skip_notifications=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
async def upsert_subscription(session: AsyncSession,
|
async def upsert_subscription(session: AsyncSession,
|
||||||
sub_payload: Dict[str, Any]) -> Subscription:
|
sub_payload: Dict[str, Any]) -> Subscription:
|
||||||
panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
|
panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
|
||||||
|
|||||||
+1
-14
@@ -30,20 +30,7 @@ async def get_user_by_panel_uuid(
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_user(
|
## Removed unused generic get_user helper to keep DAL explicit and simple
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
user_id: Optional[int] = None,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
panel_uuid: Optional[str] = None,
|
|
||||||
) -> Optional[User]:
|
|
||||||
if user_id is not None:
|
|
||||||
return await get_user_by_id(session, user_id)
|
|
||||||
if username is not None:
|
|
||||||
return await get_user_by_username(session, username)
|
|
||||||
if panel_uuid is not None:
|
|
||||||
return await get_user_by_panel_uuid(session, panel_uuid)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
|
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"choose_language": "Choose language / Выберите язык:",
|
"choose_language": "Choose language / Выберите язык:",
|
||||||
"language_set_alert": "Language changed!",
|
"language_set_alert": "Language changed!",
|
||||||
"error_occurred_try_again": "An error occurred, please try again.",
|
"error_occurred_try_again": "An error occurred, please try again.",
|
||||||
|
"error_try_again": "Please try again.",
|
||||||
"error_displaying_menu": "Error displaying menu.",
|
"error_displaying_menu": "Error displaying menu.",
|
||||||
"main_menu_unknown_action": "Unknown action.",
|
"main_menu_unknown_action": "Unknown action.",
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"choose_language": "Выберите язык / Select language:",
|
"choose_language": "Выберите язык / Select language:",
|
||||||
"language_set_alert": "Язык изменен!",
|
"language_set_alert": "Язык изменен!",
|
||||||
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
||||||
|
"error_try_again": "Попробуйте еще раз.",
|
||||||
"error_displaying_menu": "Ошибка отображения меню.",
|
"error_displaying_menu": "Ошибка отображения меню.",
|
||||||
"main_menu_unknown_action": "Неизвестное действие.",
|
"main_menu_unknown_action": "Неизвестное действие.",
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user