Code Refactoring
This commit is contained in:
@@ -1,97 +1,114 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from apscheduler .schedulers .asyncio import AsyncIOScheduler
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from datetime import datetime
|
||||
|
||||
from config .settings import Settings
|
||||
from config.settings import Settings
|
||||
from .subscription_service import SubscriptionService
|
||||
|
||||
from db .database import get_db_connection_manager
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from db.database import get_db_connection_manager
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
|
||||
async def send_expiration_warnings (bot :Bot ,settings :Settings ,i18n :JsonI18n ,panel_service :PanelApiService ):
|
||||
async def send_expiration_warnings(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService):
|
||||
"""
|
||||
Sends expiration warnings to users whose subscriptions are ending soon.
|
||||
This function is called by the scheduler.
|
||||
"""
|
||||
logging .info (f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}...")
|
||||
logging.info(
|
||||
f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..."
|
||||
)
|
||||
|
||||
sub_service = SubscriptionService(get_db_connection_manager, settings,
|
||||
panel_service)
|
||||
|
||||
sub_service =SubscriptionService (get_db_connection_manager ,settings ,panel_service )
|
||||
expiring_subs = await sub_service.get_subscriptions_ending_soon(
|
||||
settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
|
||||
|
||||
expiring_subs =await sub_service .get_subscriptions_ending_soon (settings .SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS )
|
||||
|
||||
if not expiring_subs :
|
||||
logging .info ("No subscriptions found ending soon for notification.")
|
||||
if not expiring_subs:
|
||||
logging.info("No subscriptions found ending soon for notification.")
|
||||
return
|
||||
|
||||
logging .info (f"Found {len(expiring_subs)} subscriptions ending soon for notification.")
|
||||
for sub_info in expiring_subs :
|
||||
user_id =sub_info ['user_id']
|
||||
if not user_id :
|
||||
logging .warning (f"Skipping notification for subscription without user_id: {sub_info}")
|
||||
logging.info(
|
||||
f"Found {len(expiring_subs)} subscriptions ending soon for notification."
|
||||
)
|
||||
for sub_info in expiring_subs:
|
||||
user_id = sub_info['user_id']
|
||||
if not user_id:
|
||||
logging.warning(
|
||||
f"Skipping notification for subscription without user_id: {sub_info}"
|
||||
)
|
||||
continue
|
||||
|
||||
user_lang =sub_info .get ('language_code')if sub_info .get ('language_code')else getattr (settings ,'DEFAULT_LANGUAGE','en')
|
||||
first_name =sub_info .get ('first_name','User')
|
||||
end_date_str =sub_info ['end_date_str']
|
||||
user_lang = sub_info.get('language_code') if sub_info.get(
|
||||
'language_code') else getattr(settings, 'DEFAULT_LANGUAGE', 'en')
|
||||
first_name = sub_info.get('first_name', 'User')
|
||||
end_date_str = sub_info['end_date_str']
|
||||
|
||||
days_left_float =sub_info .get ('days_left')
|
||||
days_left_display ='N/A'
|
||||
if days_left_float is not None :
|
||||
days_left_float = sub_info.get('days_left')
|
||||
days_left_display = 'N/A'
|
||||
if days_left_float is not None:
|
||||
|
||||
days_left_display =max (0 ,int (round (days_left_float )))
|
||||
days_left_display = max(0, int(round(days_left_float)))
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs )
|
||||
message_text =_ (
|
||||
"subscription_ending_soon_notification",
|
||||
user_name =first_name ,
|
||||
end_date =end_date_str ,
|
||||
days_left =days_left_display
|
||||
)
|
||||
try :
|
||||
await bot .send_message (user_id ,message_text )
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
message_text = _("subscription_ending_soon_notification",
|
||||
user_name=first_name,
|
||||
end_date=end_date_str,
|
||||
days_left=days_left_display)
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
|
||||
await sub_service .update_last_notification_sent (user_id ,end_date_str )
|
||||
logging .info (f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}")
|
||||
except Exception as e :
|
||||
await sub_service.update_last_notification_sent(
|
||||
user_id, end_date_str)
|
||||
logging.info(
|
||||
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}"
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
logging .error (f"Failed to send expiration warning to user {user_id}: {e}")
|
||||
await asyncio .sleep (0.1 )
|
||||
logging.error(
|
||||
f"Failed to send expiration warning to user {user_id}: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def schedule_subscription_notifications (bot :Bot ,settings :Settings ,i18n :JsonI18n ,scheduler :AsyncIOScheduler ):
|
||||
async def schedule_subscription_notifications(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
scheduler: AsyncIOScheduler):
|
||||
"""Schedules the daily job to send expiration warnings."""
|
||||
|
||||
async def job_wrapper():
|
||||
panel_service = PanelApiService(settings)
|
||||
try:
|
||||
|
||||
async def job_wrapper ():
|
||||
panel_service =PanelApiService (settings )
|
||||
try :
|
||||
await send_expiration_warnings(bot, settings, i18n, panel_service)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error in scheduled job 'send_expiration_warnings': {e}",
|
||||
exc_info=True)
|
||||
finally:
|
||||
await panel_service.close_session()
|
||||
|
||||
await send_expiration_warnings (bot ,settings ,i18n ,panel_service )
|
||||
except Exception as e :
|
||||
logging .error (f"Error in scheduled job 'send_expiration_warnings': {e}",exc_info =True )
|
||||
finally :
|
||||
await panel_service .close_session ()
|
||||
try:
|
||||
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
|
||||
notification_minute = int(
|
||||
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
|
||||
except (ValueError, TypeError):
|
||||
logging.warning(
|
||||
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC."
|
||||
)
|
||||
notification_hour = 9
|
||||
notification_minute = 0
|
||||
|
||||
|
||||
try :
|
||||
notification_hour =int (settings .SUBSCRIPTION_NOTIFICATION_HOUR_UTC )
|
||||
notification_minute =int (settings .SUBSCRIPTION_NOTIFICATION_MINUTE_UTC )
|
||||
except (ValueError ,TypeError ):
|
||||
logging .warning ("SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC.")
|
||||
notification_hour =9
|
||||
notification_minute =0
|
||||
|
||||
scheduler .add_job (
|
||||
job_wrapper ,
|
||||
'cron',
|
||||
hour =notification_hour ,
|
||||
minute =notification_minute ,
|
||||
name ="daily_subscription_expiration_warnings",
|
||||
misfire_grace_time =60 *15
|
||||
scheduler.add_job(job_wrapper,
|
||||
'cron',
|
||||
hour=notification_hour,
|
||||
minute=notification_minute,
|
||||
name="daily_subscription_expiration_warnings",
|
||||
misfire_grace_time=60 * 15)
|
||||
logging.info(
|
||||
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
|
||||
)
|
||||
logging .info (f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC.")
|
||||
+219
-143
@@ -1,183 +1,259 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
from typing import Optional ,List ,Dict ,Any
|
||||
from datetime import datetime ,timedelta ,timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from config .settings import Settings
|
||||
from config.settings import Settings
|
||||
|
||||
class PanelApiService :
|
||||
def __init__ (self ,settings :Settings ):
|
||||
self .settings =settings
|
||||
self .base_url =settings .PANEL_API_URL
|
||||
self .api_key =settings .PANEL_API_KEY
|
||||
self ._session :Optional [aiohttp .ClientSession ]=None
|
||||
self .default_client_ip ="127.0.0.1"
|
||||
|
||||
async def _get_session (self )->aiohttp .ClientSession :
|
||||
if self ._session is None or self ._session .closed :
|
||||
self ._session =aiohttp .ClientSession ()
|
||||
return self ._session
|
||||
class PanelApiService:
|
||||
|
||||
async def close_session (self ):
|
||||
if self ._session and not self ._session .closed :
|
||||
await self ._session .close ()
|
||||
self ._session =None
|
||||
logging .info ("Panel API service session closed.")
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.base_url = settings.PANEL_API_URL
|
||||
self.api_key = settings.PANEL_API_KEY
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self.default_client_ip = "127.0.0.1"
|
||||
|
||||
async def _prepare_headers (self )->Dict [str ,str ]:
|
||||
headers ={
|
||||
"Content-Type":"application/json",
|
||||
"X-Forwarded-Proto":"https",
|
||||
"X-Forwarded-For":self .default_client_ip ,
|
||||
"X-Real-IP":self .default_client_ip ,
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close_session(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
logging.info("Panel API service session closed.")
|
||||
|
||||
async def _prepare_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-For": self.default_client_ip,
|
||||
"X-Real-IP": self.default_client_ip,
|
||||
}
|
||||
if self .api_key :
|
||||
headers ["Authorization"]=f"Bearer {self.api_key}"
|
||||
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
return headers
|
||||
|
||||
async def _request (self ,method :str ,endpoint :str ,**kwargs )->Optional [Dict [str ,Any ]]:
|
||||
if not self .base_url :
|
||||
logging .error ("Panel API URL not configured.")
|
||||
return {"error":True ,"status_code":0 ,"message":"Panel API URL not configured."}
|
||||
async def _request(self, method: str, endpoint: str,
|
||||
**kwargs) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
logging.error("Panel API URL not configured.")
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 0,
|
||||
"message": "Panel API URL not configured."
|
||||
}
|
||||
|
||||
session =await self ._get_session ()
|
||||
headers =await self ._prepare_headers ()
|
||||
session = await self._get_session()
|
||||
headers = await self._prepare_headers()
|
||||
|
||||
if "Authorization"not in headers and self .api_key :
|
||||
logging .warning (f"Authorization header missing for panel endpoint {endpoint} despite API key being set.")
|
||||
if "Authorization" not in headers and self.api_key:
|
||||
logging.warning(
|
||||
f"Authorization header missing for panel endpoint {endpoint} despite API key being set."
|
||||
)
|
||||
|
||||
url =f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
json_payload_for_log =kwargs .get ('json')if method in ["POST","PATCH","PUT"]else None
|
||||
log_prefix =f"Panel API {method} {url}"
|
||||
if json_payload_for_log :log_prefix +=f" Payload: {json_payload_for_log}"
|
||||
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
json_payload_for_log = kwargs.get('json') if method in [
|
||||
"POST", "PATCH", "PUT"
|
||||
] else None
|
||||
log_prefix = f"Panel API {method} {url}"
|
||||
if json_payload_for_log:
|
||||
log_prefix += f" Payload: {json_payload_for_log}"
|
||||
|
||||
try :
|
||||
async with session .request (method ,url ,headers =headers ,**kwargs )as response :
|
||||
if 200 <=response .status <300 :
|
||||
try :
|
||||
data =await response .json ()
|
||||
logging .debug (f"{log_prefix} - Success ({response.status})")
|
||||
try:
|
||||
async with session.request(method, url, headers=headers,
|
||||
**kwargs) as response:
|
||||
if 200 <= response.status < 300:
|
||||
try:
|
||||
data = await response.json()
|
||||
logging.debug(
|
||||
f"{log_prefix} - Success ({response.status})")
|
||||
return data
|
||||
except aiohttp .ContentTypeError :
|
||||
logging .debug (f"{log_prefix} - Success ({response.status}) with non-JSON response.")
|
||||
return {"status":"success","code":response .status ,"data_text":await response .text ()}
|
||||
else :
|
||||
try :
|
||||
error_json =await response .json ()
|
||||
logging .error (f"{log_prefix} - Failed ({response.status}): {error_json}")
|
||||
return {"error":True ,"status_code":response .status ,"response":error_json ,
|
||||
"message":error_json .get ("message"),"errorCode":error_json .get ("errorCode")}
|
||||
except aiohttp .ContentTypeError :
|
||||
error_text =await response .text ()
|
||||
logging .error (f"{log_prefix} - Failed ({response.status}): {error_text}")
|
||||
return {"error":True ,"status_code":response .status ,"message":error_text }
|
||||
except aiohttp .ClientError as e :
|
||||
logging .error (f"Panel API client request error to {url}: {e}")
|
||||
return {"error":True ,"status_code":-1 ,"message":str (e )}
|
||||
except Exception as e :
|
||||
logging .error (f"Unexpected Panel API request error to {url}: {e}",exc_info =True )
|
||||
return {"error":True ,"status_code":-2 ,"message":f"Unexpected error: {str(e)}"}
|
||||
except aiohttp.ContentTypeError:
|
||||
logging.debug(
|
||||
f"{log_prefix} - Success ({response.status}) with non-JSON response."
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"code": response.status,
|
||||
"data_text": await response.text()
|
||||
}
|
||||
else:
|
||||
try:
|
||||
error_json = await response.json()
|
||||
logging.error(
|
||||
f"{log_prefix} - Failed ({response.status}): {error_json}"
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": response.status,
|
||||
"response": error_json,
|
||||
"message": error_json.get("message"),
|
||||
"errorCode": error_json.get("errorCode")
|
||||
}
|
||||
except aiohttp.ContentTypeError:
|
||||
error_text = await response.text()
|
||||
logging.error(
|
||||
f"{log_prefix} - Failed ({response.status}): {error_text}"
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": response.status,
|
||||
"message": error_text
|
||||
}
|
||||
except aiohttp.ClientError as e:
|
||||
logging.error(f"Panel API client request error to {url}: {e}")
|
||||
return {"error": True, "status_code": -1, "message": str(e)}
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected Panel API request error to {url}: {e}",
|
||||
exc_info=True)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -2,
|
||||
"message": f"Unexpected error: {str(e)}"
|
||||
}
|
||||
|
||||
async def get_users_by_filter (self ,username :Optional [str ]=None )->Optional [List [Dict [str ,Any ]]]:
|
||||
async def get_users_by_filter(
|
||||
self,
|
||||
username: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Fetches users from panel by username."""
|
||||
if not username :
|
||||
logging .warning ("get_users_by_filter called without username.")
|
||||
if not username:
|
||||
logging.warning("get_users_by_filter called without username.")
|
||||
return None
|
||||
|
||||
params ={"username":username }
|
||||
response_data =await self ._request ("GET","/users",params =params )
|
||||
params = {"username": username}
|
||||
response_data = await self._request("GET", "/users", params=params)
|
||||
|
||||
if response_data and not response_data .get ("error"):
|
||||
users_list =response_data .get ("response",{}).get ("users",[])
|
||||
logging .info (f"Found {len(users_list)} panel users matching filter: {params}")
|
||||
if response_data and not response_data.get("error"):
|
||||
users_list = response_data.get("response", {}).get("users", [])
|
||||
logging.info(
|
||||
f"Found {len(users_list)} panel users matching filter: {params}"
|
||||
)
|
||||
return users_list
|
||||
logging .error (f"Failed to fetch panel users with filter {params}. Response: {response_data}")
|
||||
logging.error(
|
||||
f"Failed to fetch panel users with filter {params}. Response: {response_data}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def create_panel_user (
|
||||
self ,
|
||||
username :str ,
|
||||
telegram_id :Optional [int ]=None ,
|
||||
email :Optional [str ]=None ,
|
||||
default_expire_days :int =1 ,
|
||||
default_traffic_limit_bytes :int =0 ,
|
||||
default_traffic_limit_strategy :str ="NO_RESET",
|
||||
specific_inbound_uuids :Optional [List [str ]]=None ,
|
||||
activate_all_inbounds_default_flag :bool =True
|
||||
)->Optional [Dict [str ,Any ]]:
|
||||
async def create_panel_user(
|
||||
self,
|
||||
username: str,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
default_expire_days: int = 1,
|
||||
default_traffic_limit_bytes: int = 0,
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
specific_inbound_uuids: Optional[List[str]] = None,
|
||||
activate_all_inbounds_default_flag: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not (6 <=len (username )<=34 and username .replace ('_','').replace ('-','').isalnum ()):
|
||||
msg =f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
|
||||
logging .error (msg )
|
||||
return {"error":True ,"status_code":400 ,"message":msg ,"response":{"message":msg ,"errorCode":"VALIDATION_ERROR"}}
|
||||
if not (6 <= len(username) <= 34
|
||||
and username.replace('_', '').replace('-', '').isalnum()):
|
||||
msg = f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"response": {
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
now =datetime .now (timezone .utc )
|
||||
expire_at_dt =now +timedelta (days =default_expire_days )
|
||||
expire_at_iso =expire_at_dt .isoformat (timespec ='milliseconds').replace ('+00:00','Z')
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at_dt = now + timedelta(days=default_expire_days)
|
||||
expire_at_iso = expire_at_dt.isoformat(
|
||||
timespec='milliseconds').replace('+00:00', 'Z')
|
||||
|
||||
payload :Dict [str ,Any ]={
|
||||
"username":username ,
|
||||
"expireAt":expire_at_iso ,
|
||||
"trafficLimitStrategy":default_traffic_limit_strategy ,
|
||||
"trafficLimitBytes":default_traffic_limit_bytes ,
|
||||
payload: Dict[str, Any] = {
|
||||
"username": username,
|
||||
"expireAt": expire_at_iso,
|
||||
"trafficLimitStrategy": default_traffic_limit_strategy,
|
||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||
}
|
||||
if specific_inbound_uuids :
|
||||
payload ["activeUserInbounds"]=specific_inbound_uuids
|
||||
if specific_inbound_uuids:
|
||||
payload["activeUserInbounds"] = specific_inbound_uuids
|
||||
|
||||
payload["activateAllInbounds"] = False
|
||||
else:
|
||||
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
|
||||
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
if email: payload["email"] = email
|
||||
|
||||
payload ["activateAllInbounds"]=False
|
||||
else :
|
||||
payload ["activateAllInbounds"]=activate_all_inbounds_default_flag
|
||||
return await self._request("POST", "/users", json=payload)
|
||||
|
||||
if telegram_id is not None :payload ["telegramId"]=telegram_id
|
||||
if email :payload ["email"]=email
|
||||
async def update_user_details_on_panel(
|
||||
self, user_uuid: str,
|
||||
update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if 'uuid' not in update_payload: update_payload['uuid'] = user_uuid
|
||||
|
||||
return await self ._request ("POST","/users",json =payload )
|
||||
update_payload.pop('activateAllInbounds', None)
|
||||
|
||||
async def update_user_details_on_panel (self ,user_uuid :str ,update_payload :Dict [str ,Any ])->Optional [Dict [str ,Any ]]:
|
||||
if 'uuid'not in update_payload :update_payload ['uuid']=user_uuid
|
||||
|
||||
|
||||
|
||||
|
||||
update_payload .pop ('activateAllInbounds',None )
|
||||
|
||||
full_response =await self ._request ("PATCH","/users",json =update_payload )
|
||||
if full_response and not full_response .get ("error")and full_response .get ("response"):
|
||||
logging .info (f"User {user_uuid} details updated on panel.")
|
||||
return full_response .get ("response")
|
||||
logging .error (f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}")
|
||||
full_response = await self._request("PATCH",
|
||||
"/users",
|
||||
json=update_payload)
|
||||
if full_response and not full_response.get(
|
||||
"error") and full_response.get("response"):
|
||||
logging.info(f"User {user_uuid} details updated on panel.")
|
||||
return full_response.get("response")
|
||||
logging.error(
|
||||
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_all_panel_users (self ,page_size :int =100 )->Optional [List [Dict [str ,Any ]]]:
|
||||
all_users =[];start_offset =0
|
||||
while True :
|
||||
params ={"size":page_size ,"start":start_offset }
|
||||
response_data =await self ._request ("GET","/users",params =params )
|
||||
if not response_data or response_data .get ("error"):logging .error (f"Failed to fetch panel users batch: {response_data}");return None
|
||||
users_batch =response_data .get ("response",{}).get ("users",[])
|
||||
if not users_batch :break
|
||||
all_users .extend (users_batch )
|
||||
if len (users_batch )<page_size :break
|
||||
start_offset +=page_size
|
||||
logging .info (f"Fetched {len(all_users)} users from panel API.");return all_users
|
||||
async def get_all_panel_users(self,
|
||||
page_size: int = 100
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
all_users = []
|
||||
start_offset = 0
|
||||
while True:
|
||||
params = {"size": page_size, "start": start_offset}
|
||||
response_data = await self._request("GET", "/users", params=params)
|
||||
if not response_data or response_data.get("error"):
|
||||
logging.error(
|
||||
f"Failed to fetch panel users batch: {response_data}")
|
||||
return None
|
||||
users_batch = response_data.get("response", {}).get("users", [])
|
||||
if not users_batch: break
|
||||
all_users.extend(users_batch)
|
||||
if len(users_batch) < page_size: break
|
||||
start_offset += page_size
|
||||
logging.info(f"Fetched {len(all_users)} users from panel API.")
|
||||
return all_users
|
||||
|
||||
async def get_user_by_uuid (self ,user_uuid :str )->Optional [Dict [str ,Any ]]:
|
||||
full_response =await self ._request ("GET",f"/users/{user_uuid}")
|
||||
if full_response and not full_response .get ("error")and full_response .get ("response"):
|
||||
return full_response .get ("response")
|
||||
async def get_user_by_uuid(self,
|
||||
user_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
full_response = await self._request("GET", f"/users/{user_uuid}")
|
||||
if full_response and not full_response.get(
|
||||
"error") and full_response.get("response"):
|
||||
return full_response.get("response")
|
||||
return None
|
||||
|
||||
async def update_user_status_on_panel (self ,user_uuid :str ,enable :bool )->bool :
|
||||
endpoint =f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
|
||||
response_data =await self ._request ("POST",endpoint )
|
||||
if response_data and not response_data .get ("error")and (response_data .get ("response")or response_data .get ("status")=="success"):
|
||||
logging .info (f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}.");return True
|
||||
logging .error (f"Failed to update user {user_uuid} status on panel. Resp: {response_data}");return False
|
||||
async def update_user_status_on_panel(self, user_uuid: str,
|
||||
enable: bool) -> bool:
|
||||
endpoint = f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
|
||||
response_data = await self._request("POST", endpoint)
|
||||
if response_data and not response_data.get("error") and (
|
||||
response_data.get("response")
|
||||
or response_data.get("status") == "success"):
|
||||
logging.info(
|
||||
f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}."
|
||||
)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to update user {user_uuid} status on panel. Resp: {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_subscription_link (self ,short_uuid_or_sub_uuid :str ,client_type :Optional [str ]=None )->Optional [str ]:
|
||||
if not self .settings .PANEL_API_URL :return None
|
||||
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
|
||||
async def get_subscription_link(
|
||||
self,
|
||||
short_uuid_or_sub_uuid: str,
|
||||
client_type: Optional[str] = None) -> Optional[str]:
|
||||
if not self.settings.PANEL_API_URL: return None
|
||||
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
|
||||
|
||||
+160
-116
@@ -1,144 +1,188 @@
|
||||
import uuid
|
||||
import logging
|
||||
from yookassa import Configuration ,Payment
|
||||
from yookassa .domain .request .payment_request_builder import PaymentRequestBuilder
|
||||
from yookassa import Configuration, Payment
|
||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class YooKassaService:
|
||||
|
||||
def __init__(self,
|
||||
shop_id: Optional[str],
|
||||
secret_key: Optional[str],
|
||||
configured_return_url: Optional[str],
|
||||
bot_username_for_default: Optional[str] = None,
|
||||
settings_obj: Optional[Settings] = None):
|
||||
self.settings = settings_obj
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
logging.warning(
|
||||
"YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled."
|
||||
)
|
||||
self.configured = False
|
||||
else:
|
||||
Configuration.account_id = shop_id
|
||||
Configuration.secret_key = secret_key
|
||||
self.configured = True
|
||||
logging.info(f"YooKassa configured for shop_id: {shop_id}")
|
||||
|
||||
if configured_return_url:
|
||||
self.return_url = configured_return_url
|
||||
elif bot_username_for_default:
|
||||
self.return_url = f"https://t.me/{bot_username_for_default}"
|
||||
logging.info(
|
||||
f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}"
|
||||
)
|
||||
else:
|
||||
self.return_url = "https://example.com/payment_error_no_return_url"
|
||||
logging.warning(
|
||||
f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}"
|
||||
)
|
||||
logging.info(
|
||||
f"YooKassa Service effective return_url: {self.return_url}")
|
||||
|
||||
|
||||
from typing import Optional ,Dict ,Any ,List
|
||||
|
||||
from config .settings import Settings
|
||||
|
||||
class YooKassaService :
|
||||
def __init__ (
|
||||
self ,
|
||||
shop_id :Optional [str ],
|
||||
secret_key :Optional [str ],
|
||||
configured_return_url :Optional [str ],
|
||||
bot_username_for_default :Optional [str ]=None ,
|
||||
settings_obj :Optional [Settings ]=None
|
||||
):
|
||||
self .settings =settings_obj
|
||||
|
||||
if not shop_id or not secret_key :
|
||||
logging .warning ("YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled.")
|
||||
self .configured =False
|
||||
else :
|
||||
Configuration .account_id =shop_id
|
||||
Configuration .secret_key =secret_key
|
||||
self .configured =True
|
||||
logging .info (f"YooKassa configured for shop_id: {shop_id}")
|
||||
|
||||
if configured_return_url :
|
||||
self .return_url =configured_return_url
|
||||
elif bot_username_for_default :
|
||||
self .return_url =f"https://t.me/{bot_username_for_default}"
|
||||
logging .info (f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}")
|
||||
else :
|
||||
self .return_url ="https://example.com/payment_error_no_return_url"
|
||||
logging .warning (f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}")
|
||||
logging .info (f"YooKassa Service effective return_url: {self.return_url}")
|
||||
|
||||
|
||||
async def create_payment (self ,amount :float ,currency :str ,description :str ,metadata :Dict [str ,Any ])->Optional [Dict [str ,Any ]]:
|
||||
if not self .configured :
|
||||
logging .error ("YooKassa is not configured. Cannot create payment.")
|
||||
async def create_payment(
|
||||
self, amount: float, currency: str, description: str,
|
||||
metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("YooKassa is not configured. Cannot create payment.")
|
||||
return None
|
||||
|
||||
if not self .settings :
|
||||
logging .error ("YooKassaService: Settings object not available for receipt creation.")
|
||||
return {"error":True ,"internal_message":"Service settings not initialized."}
|
||||
|
||||
if not self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL :
|
||||
logging .error ("CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact.")
|
||||
return {"error":True ,"internal_message":"YooKassa receipt email not configured by admin."}
|
||||
|
||||
try :
|
||||
builder =PaymentRequestBuilder ()
|
||||
builder .set_amount ({"value":str (round (amount ,2 )),"currency":currency .upper ()})
|
||||
builder .set_capture (True )
|
||||
builder .set_confirmation ({"type":"redirect","return_url":self .return_url })
|
||||
builder .set_description (description )
|
||||
builder .set_metadata (metadata )
|
||||
|
||||
|
||||
receipt_items :List [Dict [str ,Any ]]=[
|
||||
{
|
||||
"description":description ,
|
||||
"quantity":"1.00",
|
||||
"amount":{
|
||||
"value":str (round (amount ,2 )),
|
||||
"currency":currency .upper ()
|
||||
},
|
||||
"vat_code":str (self .settings .YOOKASSA_VAT_CODE ),
|
||||
"payment_mode":self .settings .YOOKASSA_PAYMENT_MODE ,
|
||||
"payment_subject":self .settings .YOOKASSA_PAYMENT_SUBJECT
|
||||
}
|
||||
]
|
||||
|
||||
receipt_customer :Dict [str ,str ]={}
|
||||
if self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL :
|
||||
receipt_customer ["email"]=self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
|
||||
|
||||
|
||||
if not receipt_customer :
|
||||
logging .error ("YooKassa: No customer contact (email/phone) for receipt.")
|
||||
return {"error":True ,"internal_message":"Receipt customer contact missing."}
|
||||
|
||||
receipt_payload :Dict [str ,Any ]={
|
||||
"customer":receipt_customer ,
|
||||
"items":receipt_items
|
||||
if not self.settings:
|
||||
logging.error(
|
||||
"YooKassaService: Settings object not available for receipt creation."
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Service settings not initialized."
|
||||
}
|
||||
|
||||
if not self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
logging.error(
|
||||
"CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact."
|
||||
)
|
||||
return {
|
||||
"error":
|
||||
True,
|
||||
"internal_message":
|
||||
"YooKassa receipt email not configured by admin."
|
||||
}
|
||||
|
||||
builder .set_receipt (receipt_payload )
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
builder.set_amount({
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
builder.set_capture(True)
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
receipt_items: List[Dict[str, Any]] = [{
|
||||
"description":
|
||||
description,
|
||||
"quantity":
|
||||
"1.00",
|
||||
"amount": {
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"vat_code":
|
||||
str(self.settings.YOOKASSA_VAT_CODE),
|
||||
"payment_mode":
|
||||
self.settings.YOOKASSA_PAYMENT_MODE,
|
||||
"payment_subject":
|
||||
self.settings.YOOKASSA_PAYMENT_SUBJECT
|
||||
}]
|
||||
|
||||
receipt_customer: Dict[str, str] = {}
|
||||
if self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
receipt_customer[
|
||||
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
if not receipt_customer:
|
||||
logging.error(
|
||||
"YooKassa: No customer contact (email/phone) for receipt.")
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Receipt customer contact missing."
|
||||
}
|
||||
|
||||
receipt_payload: Dict[str, Any] = {
|
||||
"customer": receipt_customer,
|
||||
"items": receipt_items
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_payload)
|
||||
|
||||
idempotence_key =str (uuid .uuid4 ())
|
||||
payment_request =builder .build ()
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
payment_request = builder.build()
|
||||
|
||||
logging .info (f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}")
|
||||
logging.info(
|
||||
f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}"
|
||||
)
|
||||
|
||||
res =Payment .create (payment_request ,idempotence_key )
|
||||
logging .info (f"YooKassa Payment.create response: ID={res.id}, Status={res.status}")
|
||||
res = Payment.create(payment_request, idempotence_key)
|
||||
logging.info(
|
||||
f"YooKassa Payment.create response: ID={res.id}, Status={res.status}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id":res .id ,
|
||||
"confirmation_url":res .confirmation .confirmation_url if res .confirmation else None ,
|
||||
"status":res .status ,
|
||||
"metadata":res .metadata ,
|
||||
"amount_value":float (res .amount .value ),
|
||||
"amount_currency":res .amount .currency ,
|
||||
"idempotence_key":idempotence_key ,
|
||||
"paid":res .paid ,
|
||||
"refundable":res .refundable ,
|
||||
"created_at":res .created_at .isoformat ()if hasattr (res .created_at ,'isoformat')else str (res .created_at )
|
||||
"id":
|
||||
res.id,
|
||||
"confirmation_url":
|
||||
res.confirmation.confirmation_url
|
||||
if res.confirmation else None,
|
||||
"status":
|
||||
res.status,
|
||||
"metadata":
|
||||
res.metadata,
|
||||
"amount_value":
|
||||
float(res.amount.value),
|
||||
"amount_currency":
|
||||
res.amount.currency,
|
||||
"idempotence_key":
|
||||
idempotence_key,
|
||||
"paid":
|
||||
res.paid,
|
||||
"refundable":
|
||||
res.refundable,
|
||||
"created_at":
|
||||
res.created_at.isoformat() if hasattr(
|
||||
res.created_at, 'isoformat') else str(res.created_at)
|
||||
}
|
||||
except Exception as e :
|
||||
logging .error (f"YooKassa payment creation failed: {e}",exc_info =True )
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa payment creation failed: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info (self ,payment_id :str )->Optional [Dict [str ,Any ]]:
|
||||
if not self .configured :
|
||||
logging .error ("YooKassa is not configured. Cannot get payment info.")
|
||||
async def get_payment_info(self,
|
||||
payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error(
|
||||
"YooKassa is not configured. Cannot get payment info.")
|
||||
return None
|
||||
try :
|
||||
payment_info =Payment .find_one (payment_id )
|
||||
if payment_info :
|
||||
return {"id":payment_info .id ,"status":payment_info .status ,"paid":payment_info .paid ,
|
||||
"amount_value":float (payment_info .amount .value ),"amount_currency":payment_info .amount .currency ,
|
||||
"metadata":payment_info .metadata ,"description":payment_info .description ,}
|
||||
try:
|
||||
payment_info = Payment.find_one(payment_id)
|
||||
if payment_info:
|
||||
return {
|
||||
"id": payment_info.id,
|
||||
"status": payment_info.status,
|
||||
"paid": payment_info.paid,
|
||||
"amount_value": float(payment_info.amount.value),
|
||||
"amount_currency": payment_info.amount.currency,
|
||||
"metadata": payment_info.metadata,
|
||||
"description": payment_info.description,
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"YooKassa get payment info for {payment_id} failed: {e}")
|
||||
return None
|
||||
except Exception as e :
|
||||
logging .error (f"YooKassa get payment info for {payment_id} failed: {e}")
|
||||
return None
|
||||
@@ -1,88 +1,88 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from datetime import datetime ,timezone
|
||||
from typing import Optional ,Dict ,Callable ,Any ,Tuple
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Callable, Any, Tuple
|
||||
|
||||
from aiogram import Bot
|
||||
from config .settings import Settings
|
||||
from db .database import get_promo_code_by_code ,increment_promo_activation ,get_db_connection_manager ,_setup_db_connection
|
||||
from config.settings import Settings
|
||||
from db.database import get_promo_code_by_code, increment_promo_activation, get_db_connection_manager, _setup_db_connection
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
class PromoCodeService :
|
||||
def __init__ (
|
||||
self ,
|
||||
db_conn_provider :Callable [[],Any ],
|
||||
settings :Settings ,
|
||||
subscription_service :SubscriptionService ,
|
||||
bot :Bot ,
|
||||
i18n :JsonI18n
|
||||
):
|
||||
self .db_conn_provider =db_conn_provider
|
||||
self .settings =settings
|
||||
self .subscription_service =subscription_service
|
||||
self .bot =bot
|
||||
self .i18n =i18n
|
||||
|
||||
async def apply_promo_code (self ,user_id :int ,code_input :str ,user_lang :str )->Tuple [bool ,str ]:
|
||||
class PromoCodeService:
|
||||
|
||||
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
|
||||
subscription_service: SubscriptionService, bot: Bot,
|
||||
i18n: JsonI18n):
|
||||
self.db_conn_provider = db_conn_provider
|
||||
self.settings = settings
|
||||
self.subscription_service = subscription_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def apply_promo_code(self, user_id: int, code_input: str,
|
||||
user_lang: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Applies a promo code for a user.
|
||||
Returns: (success_status: bool, message_text_for_user: str)
|
||||
"""
|
||||
_ =lambda k ,**kw :self .i18n .gettext (user_lang ,k ,**kw )
|
||||
code_input_upper =code_input .strip ().upper ()
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
async with self .db_conn_provider ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
promo_data =await get_promo_code_by_code (code_input_upper ,db_conn =db )
|
||||
async with self.db_conn_provider() as db:
|
||||
await _setup_db_connection(db)
|
||||
try:
|
||||
promo_data = await get_promo_code_by_code(code_input_upper,
|
||||
db_conn=db)
|
||||
|
||||
if not promo_data :
|
||||
return False ,_ ("promo_code_not_found",code =code_input_upper )
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found",
|
||||
code=code_input_upper)
|
||||
|
||||
if promo_data ['current_activations']>=promo_data ['max_activations']:
|
||||
return False ,_ ("promo_code_max_activations_reached",code =code_input_upper )
|
||||
if promo_data['current_activations'] >= promo_data[
|
||||
'max_activations']:
|
||||
return False, _("promo_code_max_activations_reached",
|
||||
code=code_input_upper)
|
||||
|
||||
active_sub = await self.subscription_service.get_active_subscription(
|
||||
user_id)
|
||||
if not active_sub:
|
||||
return False, _("promo_code_no_active_subscription")
|
||||
|
||||
bonus_days = promo_data['bonus_days']
|
||||
|
||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
db_conn=db,
|
||||
reason=f"promo code {code_input_upper}")
|
||||
|
||||
if new_end_date:
|
||||
|
||||
activation_success = await increment_promo_activation(
|
||||
promo_data['promo_code_id'],
|
||||
user_id,
|
||||
db_conn=db,
|
||||
payment_id=None)
|
||||
if activation_success:
|
||||
await db.commit()
|
||||
return True, _(
|
||||
"promo_code_applied_success",
|
||||
code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
new_end_date=new_end_date.strftime('%Y-%m-%d'))
|
||||
else:
|
||||
|
||||
active_sub =await self .subscription_service .get_active_subscription (user_id )
|
||||
if not active_sub :
|
||||
return False ,_ ("promo_code_no_active_subscription")
|
||||
await db.rollback()
|
||||
return False, _("promo_code_invalid_or_expired")
|
||||
else:
|
||||
await db.rollback()
|
||||
return False, _("error_applying_promo_bonus")
|
||||
|
||||
bonus_days =promo_data ['bonus_days']
|
||||
|
||||
|
||||
new_end_date =await self .subscription_service .extend_active_subscription_days (
|
||||
user_id =user_id ,
|
||||
bonus_days =bonus_days ,
|
||||
db_conn =db ,
|
||||
reason =f"promo code {code_input_upper}"
|
||||
)
|
||||
|
||||
if new_end_date :
|
||||
|
||||
|
||||
activation_success =await increment_promo_activation (promo_data ['promo_code_id'],user_id ,db_conn =db ,payment_id =None )
|
||||
if activation_success :
|
||||
await db .commit ()
|
||||
return True ,_ ("promo_code_applied_success",
|
||||
code =code_input_upper ,
|
||||
bonus_days =bonus_days ,
|
||||
new_end_date =new_end_date .strftime ('%Y-%m-%d'))
|
||||
else :
|
||||
|
||||
|
||||
|
||||
await db .rollback ()
|
||||
return False ,_ ("promo_code_invalid_or_expired")
|
||||
else :
|
||||
await db .rollback ()
|
||||
return False ,_ ("error_applying_promo_bonus")
|
||||
|
||||
except Exception as e :
|
||||
logging .error (f"Error applying promo code {code_input_upper} for user {user_id}: {e}",exc_info =True )
|
||||
await db .rollback ()
|
||||
return False ,_ ("error_try_again")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error applying promo code {code_input_upper} for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
await db.rollback()
|
||||
return False, _("error_try_again")
|
||||
|
||||
@@ -1,96 +1,129 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from typing import Optional ,Dict ,Callable ,Any ,TYPE_CHECKING ,Tuple
|
||||
from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple
|
||||
from aiogram import Bot
|
||||
from datetime import datetime
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import get_user
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from db.database import get_user
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
if TYPE_CHECKING :
|
||||
if TYPE_CHECKING:
|
||||
from .subscription_service import SubscriptionService
|
||||
|
||||
|
||||
class ReferralService :
|
||||
def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,subscription_service_instance :'SubscriptionService',bot :Bot ,i18n :JsonI18n ):
|
||||
self .db_conn_provider =db_conn_provider
|
||||
self .settings =settings
|
||||
self .subscription_service =subscription_service_instance
|
||||
self .bot =bot
|
||||
self .i18n =i18n
|
||||
class ReferralService:
|
||||
|
||||
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
|
||||
subscription_service_instance: 'SubscriptionService',
|
||||
bot: Bot, i18n: JsonI18n):
|
||||
self.db_conn_provider = db_conn_provider
|
||||
self.settings = settings
|
||||
self.subscription_service = subscription_service_instance
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def process_new_user_referral(self, referee_user_id: int,
|
||||
inviter_user_id: Optional[int]):
|
||||
if inviter_user_id and referee_user_id != inviter_user_id:
|
||||
|
||||
|
||||
async def process_new_user_referral (self ,referee_user_id :int ,inviter_user_id :Optional [int ]):
|
||||
if inviter_user_id and referee_user_id !=inviter_user_id :
|
||||
|
||||
|
||||
logging .info (f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}.")
|
||||
logging.info(
|
||||
f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}."
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
async def apply_referral_bonuses_for_payment (
|
||||
self ,referee_user_id :int ,purchased_subscription_months :int ,
|
||||
db_conn :aiosqlite .Connection
|
||||
)->Dict [str ,Any ]:
|
||||
async def apply_referral_bonuses_for_payment(
|
||||
self, referee_user_id: int, purchased_subscription_months: int,
|
||||
db_conn: aiosqlite.Connection) -> Dict[str, Any]:
|
||||
"""Applies bonuses. Notifies inviter. Returns referee bonus details."""
|
||||
referee_final_end_date :Optional [datetime ]=None
|
||||
referee_bonus_applied_days :Optional [int ]=None
|
||||
try :
|
||||
referee_user_row =await get_user (referee_user_id ,db_conn =db_conn )
|
||||
if not referee_user_row or referee_user_row ['referred_by_id']is None :
|
||||
logging .debug (f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses.")
|
||||
return {"referee_bonus_applied_days":None ,"referee_new_end_date":None }
|
||||
|
||||
inviter_user_id =referee_user_row ['referred_by_id']
|
||||
inviter_user_row =await get_user (inviter_user_id ,db_conn =db_conn )
|
||||
|
||||
referee_name =referee_user_row ['first_name']or f"User {referee_user_id}"
|
||||
|
||||
default_lang_for_placeholder =getattr (self .settings ,'DEFAULT_LANGUAGE','en')
|
||||
inviter_name =inviter_user_row ['first_name']if inviter_user_row else self .i18n .gettext (default_lang_for_placeholder ,"friend_placeholder")
|
||||
|
||||
inviter_bonus =self .settings .referral_bonus_inviter .get (purchased_subscription_months )
|
||||
referee_bonus =self .settings .referral_bonus_referee .get (purchased_subscription_months )
|
||||
|
||||
if inviter_bonus and inviter_bonus >0 and inviter_user_row :
|
||||
new_end_date_inviter =await self .subscription_service .extend_subscription_for_referral (
|
||||
user_id =inviter_user_id ,bonus_days =inviter_bonus ,db_conn =db_conn
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
referee_bonus_applied_days: Optional[int] = None
|
||||
try:
|
||||
referee_user_row = await get_user(referee_user_id, db_conn=db_conn)
|
||||
if not referee_user_row or referee_user_row[
|
||||
'referred_by_id'] is None:
|
||||
logging.debug(
|
||||
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses."
|
||||
)
|
||||
if new_end_date_inviter :
|
||||
logging .info (f"Bonus applied for inviter {inviter_user_id}.")
|
||||
try :
|
||||
inviter_lang =inviter_user_row .get ('language_code',default_lang_for_placeholder )
|
||||
_i =lambda k ,**kw :self .i18n .gettext (inviter_lang ,k ,**kw )
|
||||
await self .bot .send_message (
|
||||
inviter_user_id ,
|
||||
_i ("referral_bonus_inviter_notification_extended",
|
||||
days =inviter_bonus ,
|
||||
referee_name =referee_name ,
|
||||
new_end_date =new_end_date_inviter .strftime ('%Y-%m-%d')
|
||||
)
|
||||
)
|
||||
except Exception as e :
|
||||
logging .error (f"Failed to send bonus notification to inviter {inviter_user_id}: {e}")
|
||||
else :logging .warning (f"Failed to apply bonus subscription extension for inviter {inviter_user_id}.")
|
||||
return {
|
||||
"referee_bonus_applied_days": None,
|
||||
"referee_new_end_date": None
|
||||
}
|
||||
|
||||
if referee_bonus and referee_bonus >0 :
|
||||
new_end_date_referee =await self .subscription_service .extend_subscription_for_referral (
|
||||
user_id =referee_user_id ,bonus_days =referee_bonus ,db_conn =db_conn ,is_referee_bonus =True
|
||||
)
|
||||
if new_end_date_referee :
|
||||
logging .info (f"Bonus applied for referee {referee_user_id}.")
|
||||
referee_final_end_date =new_end_date_referee
|
||||
referee_bonus_applied_days =referee_bonus
|
||||
else :logging .warning (f"Failed to apply bonus subscription extension for referee {referee_user_id}.")
|
||||
inviter_user_id = referee_user_row['referred_by_id']
|
||||
inviter_user_row = await get_user(inviter_user_id, db_conn=db_conn)
|
||||
|
||||
return {"referee_bonus_applied_days":referee_bonus_applied_days ,"referee_new_end_date":referee_final_end_date }
|
||||
except Exception as e :
|
||||
logging .error (f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",exc_info =True )
|
||||
referee_name = referee_user_row[
|
||||
'first_name'] or f"User {referee_user_id}"
|
||||
|
||||
default_lang_for_placeholder = getattr(self.settings,
|
||||
'DEFAULT_LANGUAGE', 'en')
|
||||
inviter_name = inviter_user_row[
|
||||
'first_name'] if inviter_user_row else self.i18n.gettext(
|
||||
default_lang_for_placeholder, "friend_placeholder")
|
||||
|
||||
inviter_bonus = self.settings.referral_bonus_inviter.get(
|
||||
purchased_subscription_months)
|
||||
referee_bonus = self.settings.referral_bonus_referee.get(
|
||||
purchased_subscription_months)
|
||||
|
||||
if inviter_bonus and inviter_bonus > 0 and inviter_user_row:
|
||||
new_end_date_inviter = await self.subscription_service.extend_subscription_for_referral(
|
||||
user_id=inviter_user_id,
|
||||
bonus_days=inviter_bonus,
|
||||
db_conn=db_conn)
|
||||
if new_end_date_inviter:
|
||||
logging.info(
|
||||
f"Bonus applied for inviter {inviter_user_id}.")
|
||||
try:
|
||||
inviter_lang = inviter_user_row.get(
|
||||
'language_code', default_lang_for_placeholder)
|
||||
_i = lambda k, **kw: self.i18n.gettext(
|
||||
inviter_lang, k, **kw)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i("referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus,
|
||||
referee_name=referee_name,
|
||||
new_end_date=new_end_date_inviter.strftime(
|
||||
'%Y-%m-%d')))
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Failed to apply bonus subscription extension for inviter {inviter_user_id}."
|
||||
)
|
||||
|
||||
if referee_bonus and referee_bonus > 0:
|
||||
new_end_date_referee = await self.subscription_service.extend_subscription_for_referral(
|
||||
user_id=referee_user_id,
|
||||
bonus_days=referee_bonus,
|
||||
db_conn=db_conn,
|
||||
is_referee_bonus=True)
|
||||
if new_end_date_referee:
|
||||
logging.info(
|
||||
f"Bonus applied for referee {referee_user_id}.")
|
||||
referee_final_end_date = new_end_date_referee
|
||||
referee_bonus_applied_days = referee_bonus
|
||||
else:
|
||||
logging.warning(
|
||||
f"Failed to apply bonus subscription extension for referee {referee_user_id}."
|
||||
)
|
||||
|
||||
return {
|
||||
"referee_bonus_applied_days": referee_bonus_applied_days,
|
||||
"referee_new_end_date": referee_final_end_date
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",
|
||||
exc_info=True)
|
||||
|
||||
raise
|
||||
|
||||
def generate_referral_link (self ,bot_username :str ,inviter_user_id :int )->str :
|
||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
||||
def generate_referral_link(self, bot_username: str,
|
||||
inviter_user_id: int) -> str:
|
||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
||||
|
||||
@@ -1,336 +1,670 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from datetime import datetime ,timedelta ,timezone
|
||||
from typing import Optional ,Dict ,Any ,List ,Callable ,Awaitable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any, List, Callable, Awaitable
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import get_db_connection_manager ,_setup_db_connection ,get_user ,has_had_any_subscription as db_has_had_any_subscription
|
||||
from config.settings import Settings
|
||||
from db.database import get_db_connection_manager, _setup_db_connection, get_user, has_had_any_subscription as db_has_had_any_subscription
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
|
||||
if False :
|
||||
if False:
|
||||
from .referral_service import ReferralService
|
||||
|
||||
class SubscriptionService :
|
||||
def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,panel_service :PanelApiService ):
|
||||
self .db_conn_provider =db_conn_provider
|
||||
self .settings =settings
|
||||
self .panel_service =panel_service
|
||||
|
||||
async def _get_db (self ,existing_conn :Optional [aiosqlite .Connection ]=None )->tuple [aiosqlite .Connection ,bool ]:
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
|
||||
panel_service: PanelApiService):
|
||||
self.db_conn_provider = db_conn_provider
|
||||
self.settings = settings
|
||||
self.panel_service = panel_service
|
||||
|
||||
async def _get_db(
|
||||
self,
|
||||
existing_conn: Optional[aiosqlite.Connection] = None
|
||||
) -> tuple[aiosqlite.Connection, bool]:
|
||||
"""Helper to get a DB connection. Returns (connection, should_manage_flag)."""
|
||||
if existing_conn :
|
||||
return existing_conn ,False
|
||||
if existing_conn:
|
||||
return existing_conn, False
|
||||
|
||||
conn_manager =self .db_conn_provider ()
|
||||
conn =await conn_manager .__aenter__ ()
|
||||
try :
|
||||
await _setup_db_connection (conn )
|
||||
except Exception as e :
|
||||
await conn_manager .__aexit__ (type (e ),e ,e .__traceback__ )
|
||||
conn_manager = self.db_conn_provider()
|
||||
conn = await conn_manager.__aenter__()
|
||||
try:
|
||||
await _setup_db_connection(conn)
|
||||
except Exception as e:
|
||||
await conn_manager.__aexit__(type(e), e, e.__traceback__)
|
||||
raise
|
||||
return conn ,True
|
||||
return conn, True
|
||||
|
||||
async def _release_db (self ,db :aiosqlite .Connection ,should_manage :bool ,exc_type =None ,exc_val =None ,exc_tb =None ):
|
||||
async def _release_db(self,
|
||||
db: aiosqlite.Connection,
|
||||
should_manage: bool,
|
||||
exc_type=None,
|
||||
exc_val=None,
|
||||
exc_tb=None):
|
||||
"""Helper to release/close a DB connection if this service instance opened it."""
|
||||
if should_manage :
|
||||
await db .__aexit__ (exc_type ,exc_val ,exc_tb )
|
||||
if should_manage:
|
||||
await db.__aexit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
async def get_user_language (self ,user_id :int )->str :
|
||||
async def get_user_language(self, user_id: int) -> str:
|
||||
"""Fetches the user's language preference from the local database."""
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
user_record =await get_user (user_id ,db_conn =db )
|
||||
default_lang =self .settings .DEFAULT_LANGUAGE
|
||||
return user_record ['language_code']if user_record and 'language_code'in user_record .keys ()and user_record ['language_code']else default_lang
|
||||
finally :
|
||||
await self ._release_db (db ,should_manage )
|
||||
db, should_manage = await self._get_db()
|
||||
try:
|
||||
user_record = await get_user(user_id, db_conn=db)
|
||||
default_lang = self.settings.DEFAULT_LANGUAGE
|
||||
return user_record[
|
||||
'language_code'] if user_record and 'language_code' in user_record.keys(
|
||||
) and user_record['language_code'] else default_lang
|
||||
finally:
|
||||
await self._release_db(db, should_manage)
|
||||
|
||||
async def get_panel_user_uuid (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [str ]:
|
||||
async def get_panel_user_uuid(
|
||||
self,
|
||||
user_id: int,
|
||||
db_conn: Optional[aiosqlite.Connection] = None) -> Optional[str]:
|
||||
"""Fetches panel_user_uuid for a given Telegram user_id from local DB."""
|
||||
db_to_use ,should_manage_this_conn =await self ._get_db (db_conn )
|
||||
try :
|
||||
user_record =await get_user (user_id ,db_conn =db_to_use )
|
||||
return user_record ['panel_user_uuid']if user_record and 'panel_user_uuid'in user_record .keys ()and user_record ['panel_user_uuid']else None
|
||||
finally :
|
||||
await self ._release_db (db_to_use ,should_manage_this_conn )
|
||||
db_to_use, should_manage_this_conn = await self._get_db(db_conn)
|
||||
try:
|
||||
user_record = await get_user(user_id, db_conn=db_to_use)
|
||||
return user_record[
|
||||
'panel_user_uuid'] if user_record and 'panel_user_uuid' in user_record.keys(
|
||||
) and user_record['panel_user_uuid'] else None
|
||||
finally:
|
||||
await self._release_db(db_to_use, should_manage_this_conn)
|
||||
|
||||
async def has_had_any_subscription (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
||||
async def has_had_any_subscription(
|
||||
self,
|
||||
user_id: int,
|
||||
db_conn: Optional[aiosqlite.Connection] = None) -> bool:
|
||||
"""Checks if the user has any record in the subscriptions table."""
|
||||
db_to_use ,should_manage_this_conn =await self ._get_db (db_conn )
|
||||
try :
|
||||
return await db_has_had_any_subscription (user_id ,db_conn =db_to_use )
|
||||
finally :
|
||||
await self ._release_db (db_to_use ,should_manage_this_conn )
|
||||
db_to_use, should_manage_this_conn = await self._get_db(db_conn)
|
||||
try:
|
||||
return await db_has_had_any_subscription(user_id,
|
||||
db_conn=db_to_use)
|
||||
finally:
|
||||
await self._release_db(db_to_use, should_manage_this_conn)
|
||||
|
||||
async def activate_trial_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]:
|
||||
async def activate_trial_subscription(
|
||||
self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Activates a trial subscription for an eligible user.
|
||||
Manages its own database connection and transaction.
|
||||
Returns dict with trial details or specific error dict on failure/ineligibility.
|
||||
"""
|
||||
if not self .settings .TRIAL_ENABLED or self .settings .TRIAL_DURATION_DAYS <=0 :
|
||||
logging .info (f"Trial subscription feature is disabled or duration invalid for user {user_id}.")
|
||||
return {"eligible":False ,"activated":False ,"message_key":"trial_feature_disabled"}
|
||||
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
|
||||
logging.info(
|
||||
f"Trial subscription feature is disabled or duration invalid for user {user_id}."
|
||||
)
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "trial_feature_disabled"
|
||||
}
|
||||
|
||||
async with self .db_conn_provider ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
if await db_has_had_any_subscription (user_id ,db_conn =db ):
|
||||
logging .info (f"User {user_id} has prior subscriptions. Trial not applicable.")
|
||||
return {"eligible":False ,"activated":False ,"message_key":"trial_not_eligible_already_subscribed"}
|
||||
|
||||
panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db )
|
||||
panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None
|
||||
panel_user_interacted_now =False ;specific_inbounds =self .settings .parsed_default_panel_user_inbound_uuids
|
||||
panel_actual_subscription_url :Optional [str ]=None
|
||||
|
||||
if not panel_user_uuid :
|
||||
logging .info (f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user.")
|
||||
panel_username_to_create =f"tg_{user_id}"
|
||||
creation_response =await self .panel_service .create_panel_user (
|
||||
username =panel_username_to_create ,telegram_id =user_id ,
|
||||
default_expire_days =self .settings .TRIAL_DURATION_DAYS ,
|
||||
default_traffic_limit_bytes =self .settings .trial_traffic_limit_bytes ,
|
||||
default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,
|
||||
specific_inbound_uuids =specific_inbounds ,
|
||||
activate_all_inbounds_default_flag =False if specific_inbounds else True
|
||||
async with self.db_conn_provider() as db:
|
||||
await _setup_db_connection(db)
|
||||
try:
|
||||
if await db_has_had_any_subscription(user_id, db_conn=db):
|
||||
logging.info(
|
||||
f"User {user_id} has prior subscriptions. Trial not applicable."
|
||||
)
|
||||
panel_user_obj_from_api =None
|
||||
if creation_response and not creation_response .get ("error"):panel_user_obj_from_api =creation_response .get ("response")
|
||||
elif creation_response and creation_response .get ("errorCode")=="A019":
|
||||
existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create )
|
||||
if existing_users_list and len (existing_users_list )==1 :panel_user_obj_from_api =existing_users_list [0 ]
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "trial_not_eligible_already_subscribed"
|
||||
}
|
||||
|
||||
if panel_user_obj_from_api and panel_user_obj_from_api .get ('uuid'):
|
||||
panel_user_uuid =panel_user_obj_from_api ['uuid']
|
||||
panel_subscription_uuid_for_link =panel_user_obj_from_api .get ('subscriptionUuid')
|
||||
panel_short_uuid_for_link =panel_user_obj_from_api .get ('shortUuid')
|
||||
panel_actual_subscription_url =panel_user_obj_from_api .get ('subscriptionUrl')
|
||||
await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ))
|
||||
panel_user_interacted_now =True
|
||||
if panel_user_obj_from_api .get ('telegramId')!=user_id :
|
||||
await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id })
|
||||
else :
|
||||
logging .error (f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}")
|
||||
await db .rollback ()
|
||||
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
|
||||
else :
|
||||
panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid )
|
||||
if panel_user_data :
|
||||
panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid')
|
||||
panel_short_uuid_for_link =panel_user_data .get ('shortUuid')
|
||||
panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl')
|
||||
panel_user_uuid = await self.get_panel_user_uuid(user_id,
|
||||
db_conn=db)
|
||||
panel_subscription_uuid_for_link = None
|
||||
panel_short_uuid_for_link = None
|
||||
panel_user_interacted_now = False
|
||||
specific_inbounds = self.settings.parsed_default_panel_user_inbound_uuids
|
||||
panel_actual_subscription_url: Optional[str] = None
|
||||
|
||||
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :
|
||||
panel_subscription_uuid_for_link =panel_short_uuid_for_link
|
||||
if not panel_subscription_uuid_for_link :
|
||||
logging .error (f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid}).")
|
||||
await db .rollback ()
|
||||
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
|
||||
if not panel_user_uuid:
|
||||
logging.info(
|
||||
f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user."
|
||||
)
|
||||
panel_username_to_create = f"tg_{user_id}"
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username=panel_username_to_create,
|
||||
telegram_id=user_id,
|
||||
default_expire_days=self.settings.TRIAL_DURATION_DAYS,
|
||||
default_traffic_limit_bytes=self.settings.
|
||||
trial_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.
|
||||
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
specific_inbound_uuids=specific_inbounds,
|
||||
activate_all_inbounds_default_flag=False
|
||||
if specific_inbounds else True)
|
||||
panel_user_obj_from_api = None
|
||||
if creation_response and not creation_response.get(
|
||||
"error"):
|
||||
panel_user_obj_from_api = creation_response.get(
|
||||
"response")
|
||||
elif creation_response and creation_response.get(
|
||||
"errorCode") == "A019":
|
||||
existing_users_list = await self.panel_service.get_users_by_filter(
|
||||
username=panel_username_to_create)
|
||||
if existing_users_list and len(
|
||||
existing_users_list) == 1:
|
||||
panel_user_obj_from_api = existing_users_list[0]
|
||||
|
||||
start_date =datetime .now (timezone .utc )
|
||||
end_date =start_date +timedelta (days =self .settings .TRIAL_DURATION_DAYS )
|
||||
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,))
|
||||
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """
|
||||
trial_traffic_val =self .settings .trial_traffic_limit_bytes
|
||||
params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),end_date .isoformat (),trial_traffic_val )
|
||||
if panel_user_obj_from_api and panel_user_obj_from_api.get(
|
||||
'uuid'):
|
||||
panel_user_uuid = panel_user_obj_from_api['uuid']
|
||||
panel_subscription_uuid_for_link = panel_user_obj_from_api.get(
|
||||
'subscriptionUuid')
|
||||
panel_short_uuid_for_link = panel_user_obj_from_api.get(
|
||||
'shortUuid')
|
||||
panel_actual_subscription_url = panel_user_obj_from_api.get(
|
||||
'subscriptionUrl')
|
||||
await db.execute(
|
||||
"UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
|
||||
(panel_user_uuid, user_id))
|
||||
panel_user_interacted_now = True
|
||||
if panel_user_obj_from_api.get(
|
||||
'telegramId') != user_id:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, {"telegramId": user_id})
|
||||
else:
|
||||
logging.error(
|
||||
f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}"
|
||||
)
|
||||
await db.rollback()
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed"
|
||||
}
|
||||
else:
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(
|
||||
panel_user_uuid)
|
||||
if panel_user_data:
|
||||
panel_subscription_uuid_for_link = panel_user_data.get(
|
||||
'subscriptionUuid')
|
||||
panel_short_uuid_for_link = panel_user_data.get(
|
||||
'shortUuid')
|
||||
panel_actual_subscription_url = panel_user_data.get(
|
||||
'subscriptionUrl')
|
||||
|
||||
upsert_cursor =await db .execute (upsert_sql ,params )
|
||||
trial_subscription_id =upsert_cursor .lastrowid
|
||||
if not trial_subscription_id or trial_subscription_id ==0 :
|
||||
id_fetch_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,))
|
||||
id_fetch_row =await id_fetch_cursor .fetchone ()
|
||||
if id_fetch_cursor :await id_fetch_cursor .close ()
|
||||
if id_fetch_row :trial_subscription_id =id_fetch_row ['subscription_id']
|
||||
logging .info (f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}.")
|
||||
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link:
|
||||
panel_subscription_uuid_for_link = panel_short_uuid_for_link
|
||||
if not panel_subscription_uuid_for_link:
|
||||
logging.error(
|
||||
f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid})."
|
||||
)
|
||||
await db.rollback()
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed"
|
||||
}
|
||||
|
||||
panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":trial_traffic_val ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,}
|
||||
if specific_inbounds :panel_update_payload ["activeUserInbounds"]=specific_inbounds
|
||||
elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True
|
||||
start_date = datetime.now(timezone.utc)
|
||||
end_date = start_date + timedelta(
|
||||
days=self.settings.TRIAL_DURATION_DAYS)
|
||||
await db.execute(
|
||||
"UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",
|
||||
(panel_user_uuid, ))
|
||||
upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """
|
||||
trial_traffic_val = self.settings.trial_traffic_limit_bytes
|
||||
params = (user_id, panel_user_uuid,
|
||||
panel_subscription_uuid_for_link,
|
||||
start_date.isoformat(), end_date.isoformat(),
|
||||
trial_traffic_val)
|
||||
|
||||
updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload )
|
||||
if not updated_panel_user :logging .warning (f"Panel user details update FAILED for trial user {panel_user_uuid}.")
|
||||
else :
|
||||
logging .info (f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}")
|
||||
if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl')
|
||||
if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid')
|
||||
upsert_cursor = await db.execute(upsert_sql, params)
|
||||
trial_subscription_id = upsert_cursor.lastrowid
|
||||
if not trial_subscription_id or trial_subscription_id == 0:
|
||||
id_fetch_cursor = await db.execute(
|
||||
"SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",
|
||||
(panel_subscription_uuid_for_link, ))
|
||||
id_fetch_row = await id_fetch_cursor.fetchone()
|
||||
if id_fetch_cursor: await id_fetch_cursor.close()
|
||||
if id_fetch_row:
|
||||
trial_subscription_id = id_fetch_row['subscription_id']
|
||||
logging.info(
|
||||
f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}."
|
||||
)
|
||||
|
||||
await db .commit ()
|
||||
return {"eligible":True ,"activated":True ,"end_date":end_date ,"days":self .settings .TRIAL_DURATION_DAYS ,"traffic_gb":self .settings .TRIAL_TRAFFIC_LIMIT_GB ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url }
|
||||
except Exception as e :
|
||||
logging .error (f"Error activating trial for user {user_id}: {e}",exc_info =True )
|
||||
await db .rollback ()
|
||||
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
|
||||
panel_update_payload: Dict[str, Any] = {
|
||||
"uuid":
|
||||
panel_user_uuid,
|
||||
"expireAt":
|
||||
end_date.isoformat(timespec='milliseconds').replace(
|
||||
'+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
"trafficLimitBytes":
|
||||
trial_traffic_val,
|
||||
"trafficLimitStrategy":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if specific_inbounds:
|
||||
panel_update_payload[
|
||||
"activeUserInbounds"] = specific_inbounds
|
||||
elif panel_user_interacted_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
|
||||
async def activate_subscription (
|
||||
self ,user_id :int ,months :int ,payment_amount :float ,
|
||||
payment_id_internal :int ,db_conn :aiosqlite .Connection ,
|
||||
promo_code_id :Optional [int ]=None
|
||||
)->Optional [Dict [str ,Any ]]:
|
||||
db =db_conn
|
||||
try :
|
||||
panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db )
|
||||
panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None ;panel_user_interacted_now =False
|
||||
panel_actual_subscription_url :Optional [str ]=None
|
||||
specific_inbounds_from_settings =self .settings .parsed_default_panel_user_inbound_uuids
|
||||
if not panel_user_uuid :
|
||||
panel_username_to_create =f"tg_{user_id}"
|
||||
creation_response =await self .panel_service .create_panel_user (username =panel_username_to_create ,telegram_id =user_id ,default_expire_days =self .settings .PANEL_USER_DEFAULT_EXPIRE_DAYS ,default_traffic_limit_bytes =self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,specific_inbound_uuids =specific_inbounds_from_settings ,activate_all_inbounds_default_flag =False if specific_inbounds_from_settings else True )
|
||||
panel_user_object_from_api =None
|
||||
if creation_response and not creation_response .get ("error"):panel_user_object_from_api =creation_response .get ("response")
|
||||
elif creation_response and creation_response .get ("errorCode")=="A019":
|
||||
existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create )
|
||||
if existing_users_list and len (existing_users_list )==1 :panel_user_object_from_api =existing_users_list [0 ]
|
||||
if panel_user_object_from_api and panel_user_object_from_api .get ('uuid'):
|
||||
panel_user_uuid =panel_user_object_from_api ['uuid'];panel_subscription_uuid_for_link =panel_user_object_from_api .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_object_from_api .get ('shortUuid');panel_actual_subscription_url =panel_user_object_from_api .get ('subscriptionUrl')
|
||||
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",(panel_user_uuid ,user_id ));conflicting_tg_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
|
||||
if conflicting_tg_user :logging .error (f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}.");return None
|
||||
await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ));panel_user_interacted_now =True
|
||||
if panel_user_object_from_api .get ('telegramId')!=user_id :await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id })
|
||||
else :logging .error (f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}");return None
|
||||
else :
|
||||
panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid )
|
||||
if panel_user_data :panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_data .get ('shortUuid');panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl')
|
||||
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :panel_subscription_uuid_for_link =panel_short_uuid_for_link
|
||||
if not panel_subscription_uuid_for_link :logging .error (f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}.");return None
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload)
|
||||
if not updated_panel_user:
|
||||
logging.warning(
|
||||
f"Panel user details update FAILED for trial user {panel_user_uuid}."
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}"
|
||||
)
|
||||
if updated_panel_user.get('subscriptionUrl'):
|
||||
panel_actual_subscription_url = updated_panel_user.get(
|
||||
'subscriptionUrl')
|
||||
if updated_panel_user.get('shortUuid'):
|
||||
panel_short_uuid_for_link = updated_panel_user.get(
|
||||
'shortUuid')
|
||||
|
||||
cursor =await db .execute ("SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",(panel_user_uuid ,));current_sub_row =await cursor .fetchone ();await cursor .close ()
|
||||
current_sub_end_date_str =current_sub_row ['end_date']if current_sub_row else None ;start_date =datetime .now (timezone .utc )
|
||||
if current_sub_end_date_str :
|
||||
try :
|
||||
parsed_current_end_date =datetime .fromisoformat (current_sub_end_date_str .replace ("Z","+00:00"))
|
||||
if parsed_current_end_date .tzinfo is None :parsed_current_end_date =parsed_current_end_date .replace (tzinfo =timezone .utc )
|
||||
if parsed_current_end_date >start_date :start_date =parsed_current_end_date
|
||||
except ValueError :logging .warning (f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}.")
|
||||
final_end_date =start_date +timedelta (days =months *30 )
|
||||
if promo_code_id :
|
||||
promo_cursor =await db .execute ("SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",(promo_code_id ,));promo_row =await promo_cursor .fetchone ();await promo_cursor .close ()
|
||||
if promo_row :final_end_date +=timedelta (days =promo_row ['bonus_days']);await db .execute ("INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",(promo_code_id ,user_id ,payment_id_internal ));await db .execute ("UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",(promo_code_id ,))
|
||||
await db.commit()
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": True,
|
||||
"end_date": end_date,
|
||||
"days": self.settings.TRIAL_DURATION_DAYS,
|
||||
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": panel_short_uuid_for_link,
|
||||
"subscription_url": panel_actual_subscription_url
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error activating trial for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
await db.rollback()
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed"
|
||||
}
|
||||
|
||||
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,))
|
||||
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """
|
||||
params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),final_end_date .isoformat (),months )
|
||||
upsert_cursor =await db .execute (upsert_sql ,params );subscription_id_to_return =upsert_cursor .lastrowid
|
||||
if not subscription_id_to_return or subscription_id_to_return ==0 :
|
||||
id_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,));id_row =await id_cursor .fetchone ();await id_cursor .close ()
|
||||
if id_row :subscription_id_to_return =id_row ['subscription_id']
|
||||
logging .info (f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}.")
|
||||
|
||||
panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":final_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY }
|
||||
if specific_inbounds_from_settings :panel_update_payload ["activeUserInbounds"]=specific_inbounds_from_settings
|
||||
elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True
|
||||
if "activateAllInbounds"in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now :del panel_update_payload ["activateAllInbounds"]
|
||||
logging .info (f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}")
|
||||
updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload )
|
||||
if not updated_panel_user :logging .warning (f"Panel user details update FAILED for {panel_user_uuid}.")
|
||||
else :
|
||||
logging .info (f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}")
|
||||
if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl')
|
||||
if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid')
|
||||
return {"subscription_id":subscription_id_to_return ,"end_date":final_end_date ,"is_active":True ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url }
|
||||
except Exception as e :logging .error (f"Error in activate_subscription (paid) for user {user_id}: {e}",exc_info =True );return None
|
||||
|
||||
async def extend_active_subscription_days (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,reason :str ="bonus")->Optional [datetime ]:
|
||||
db =db_conn
|
||||
try :
|
||||
user_cursor =await db .execute ("SELECT panel_user_uuid FROM users WHERE user_id = ?",(user_id ,))
|
||||
user_panel_data =await user_cursor .fetchone ()
|
||||
if user_cursor :await user_cursor .close ()
|
||||
panel_user_uuid_for_update =user_panel_data ['panel_user_uuid']if user_panel_data and 'panel_user_uuid'in user_panel_data .keys ()and user_panel_data ['panel_user_uuid']else None
|
||||
|
||||
sql_select_active_sub ="SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 "
|
||||
params_select_active_sub :tuple =(user_id ,)
|
||||
if panel_user_uuid_for_update :
|
||||
sql_select_active_sub +="AND panel_user_uuid = ? "
|
||||
params_select_active_sub +=(panel_user_uuid_for_update ,)
|
||||
else :
|
||||
logging .warning (f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical).")
|
||||
sql_select_active_sub +="ORDER BY end_date DESC LIMIT 1"
|
||||
|
||||
cursor =await db .execute (sql_select_active_sub ,params_select_active_sub )
|
||||
active_sub_row =await cursor .fetchone ()
|
||||
if cursor :await cursor .close ()
|
||||
|
||||
if not active_sub_row or not active_sub_row ['end_date']:
|
||||
logging .info (f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus.")
|
||||
async def activate_subscription(
|
||||
self,
|
||||
user_id: int,
|
||||
months: int,
|
||||
payment_amount: float,
|
||||
payment_id_internal: int,
|
||||
db_conn: aiosqlite.Connection,
|
||||
promo_code_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
db = db_conn
|
||||
try:
|
||||
panel_user_uuid = await self.get_panel_user_uuid(user_id,
|
||||
db_conn=db)
|
||||
panel_subscription_uuid_for_link = None
|
||||
panel_short_uuid_for_link = None
|
||||
panel_user_interacted_now = False
|
||||
panel_actual_subscription_url: Optional[str] = None
|
||||
specific_inbounds_from_settings = self.settings.parsed_default_panel_user_inbound_uuids
|
||||
if not panel_user_uuid:
|
||||
panel_username_to_create = f"tg_{user_id}"
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username=panel_username_to_create,
|
||||
telegram_id=user_id,
|
||||
default_expire_days=self.settings.
|
||||
PANEL_USER_DEFAULT_EXPIRE_DAYS,
|
||||
default_traffic_limit_bytes=self.settings.
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
default_traffic_limit_strategy=self.settings.
|
||||
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
specific_inbound_uuids=specific_inbounds_from_settings,
|
||||
activate_all_inbounds_default_flag=False
|
||||
if specific_inbounds_from_settings else True)
|
||||
panel_user_object_from_api = None
|
||||
if creation_response and not creation_response.get("error"):
|
||||
panel_user_object_from_api = creation_response.get(
|
||||
"response")
|
||||
elif creation_response and creation_response.get(
|
||||
"errorCode") == "A019":
|
||||
existing_users_list = await self.panel_service.get_users_by_filter(
|
||||
username=panel_username_to_create)
|
||||
if existing_users_list and len(existing_users_list) == 1:
|
||||
panel_user_object_from_api = existing_users_list[0]
|
||||
if panel_user_object_from_api and panel_user_object_from_api.get(
|
||||
'uuid'):
|
||||
panel_user_uuid = panel_user_object_from_api['uuid']
|
||||
panel_subscription_uuid_for_link = panel_user_object_from_api.get(
|
||||
'subscriptionUuid')
|
||||
panel_short_uuid_for_link = panel_user_object_from_api.get(
|
||||
'shortUuid')
|
||||
panel_actual_subscription_url = panel_user_object_from_api.get(
|
||||
'subscriptionUrl')
|
||||
cursor_conflict = await db.execute(
|
||||
"SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",
|
||||
(panel_user_uuid, user_id))
|
||||
conflicting_tg_user = await cursor_conflict.fetchone()
|
||||
await cursor_conflict.close()
|
||||
if conflicting_tg_user:
|
||||
logging.error(
|
||||
f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}."
|
||||
)
|
||||
return None
|
||||
await db.execute(
|
||||
"UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
|
||||
(panel_user_uuid, user_id))
|
||||
panel_user_interacted_now = True
|
||||
if panel_user_object_from_api.get('telegramId') != user_id:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, {"telegramId": user_id})
|
||||
else:
|
||||
logging.error(
|
||||
f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(
|
||||
panel_user_uuid)
|
||||
if panel_user_data:
|
||||
panel_subscription_uuid_for_link = panel_user_data.get(
|
||||
'subscriptionUuid')
|
||||
panel_short_uuid_for_link = panel_user_data.get(
|
||||
'shortUuid')
|
||||
panel_actual_subscription_url = panel_user_data.get(
|
||||
'subscriptionUrl')
|
||||
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link:
|
||||
panel_subscription_uuid_for_link = panel_short_uuid_for_link
|
||||
if not panel_subscription_uuid_for_link:
|
||||
logging.error(
|
||||
f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}."
|
||||
)
|
||||
return None
|
||||
|
||||
current_end_date_str =active_sub_row ['end_date']
|
||||
try :
|
||||
current_end_date =datetime .fromisoformat (current_end_date_str .replace ("Z","+00:00"))if isinstance (current_end_date_str ,str )else current_end_date_str
|
||||
if not isinstance (current_end_date ,datetime ):
|
||||
raise ValueError ("current_end_date is not a datetime object after parsing")
|
||||
except ValueError as ve :
|
||||
logging .error (f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}")
|
||||
return None
|
||||
cursor = await db.execute(
|
||||
"SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",
|
||||
(panel_user_uuid, ))
|
||||
current_sub_row = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
current_sub_end_date_str = current_sub_row[
|
||||
'end_date'] if current_sub_row else None
|
||||
start_date = datetime.now(timezone.utc)
|
||||
if current_sub_end_date_str:
|
||||
try:
|
||||
parsed_current_end_date = datetime.fromisoformat(
|
||||
current_sub_end_date_str.replace("Z", "+00:00"))
|
||||
if parsed_current_end_date.tzinfo is None:
|
||||
parsed_current_end_date = parsed_current_end_date.replace(
|
||||
tzinfo=timezone.utc)
|
||||
if parsed_current_end_date > start_date:
|
||||
start_date = parsed_current_end_date
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}."
|
||||
)
|
||||
final_end_date = start_date + timedelta(days=months * 30)
|
||||
if promo_code_id:
|
||||
promo_cursor = await db.execute(
|
||||
"SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",
|
||||
(promo_code_id, ))
|
||||
promo_row = await promo_cursor.fetchone()
|
||||
await promo_cursor.close()
|
||||
if promo_row:
|
||||
final_end_date += timedelta(days=promo_row['bonus_days'])
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",
|
||||
(promo_code_id, user_id, payment_id_internal))
|
||||
await db.execute(
|
||||
"UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",
|
||||
(promo_code_id, ))
|
||||
|
||||
if current_end_date .tzinfo is None :
|
||||
current_end_date =current_end_date .replace (tzinfo =timezone .utc )
|
||||
await db.execute(
|
||||
"UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",
|
||||
(panel_user_uuid, ))
|
||||
upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """
|
||||
params = (user_id, panel_user_uuid,
|
||||
panel_subscription_uuid_for_link, start_date.isoformat(),
|
||||
final_end_date.isoformat(), months)
|
||||
upsert_cursor = await db.execute(upsert_sql, params)
|
||||
subscription_id_to_return = upsert_cursor.lastrowid
|
||||
if not subscription_id_to_return or subscription_id_to_return == 0:
|
||||
id_cursor = await db.execute(
|
||||
"SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",
|
||||
(panel_subscription_uuid_for_link, ))
|
||||
id_row = await id_cursor.fetchone()
|
||||
await id_cursor.close()
|
||||
if id_row:
|
||||
subscription_id_to_return = id_row['subscription_id']
|
||||
logging.info(
|
||||
f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}."
|
||||
)
|
||||
|
||||
now_utc =datetime .now (timezone .utc )
|
||||
start_point_for_bonus =current_end_date if current_end_date >now_utc else now_utc
|
||||
new_end_date =start_point_for_bonus +timedelta (days =bonus_days )
|
||||
|
||||
await db .execute ("UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",(new_end_date .isoformat (),active_sub_row ['subscription_id']))
|
||||
logging .info (f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}")
|
||||
|
||||
if panel_user_uuid_for_update :
|
||||
panel_update_payload ={"uuid":panel_user_uuid_for_update ,"expireAt":new_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z')}
|
||||
if not await self .panel_service .update_user_details_on_panel (panel_user_uuid_for_update ,panel_update_payload ):
|
||||
logging .warning (f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus.")
|
||||
return new_end_date
|
||||
except Exception as e :
|
||||
logging .error (f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",exc_info =True )
|
||||
panel_update_payload: Dict[str, Any] = {
|
||||
"uuid":
|
||||
panel_user_uuid,
|
||||
"expireAt":
|
||||
final_end_date.isoformat(timespec='milliseconds').replace(
|
||||
'+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
"trafficLimitBytes":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"trafficLimitStrategy":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
|
||||
}
|
||||
if specific_inbounds_from_settings:
|
||||
panel_update_payload[
|
||||
"activeUserInbounds"] = specific_inbounds_from_settings
|
||||
elif panel_user_interacted_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
if "activateAllInbounds" in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now:
|
||||
del panel_update_payload["activateAllInbounds"]
|
||||
logging.info(
|
||||
f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}"
|
||||
)
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload)
|
||||
if not updated_panel_user:
|
||||
logging.warning(
|
||||
f"Panel user details update FAILED for {panel_user_uuid}.")
|
||||
else:
|
||||
logging.info(
|
||||
f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}"
|
||||
)
|
||||
if updated_panel_user.get('subscriptionUrl'):
|
||||
panel_actual_subscription_url = updated_panel_user.get(
|
||||
'subscriptionUrl')
|
||||
if updated_panel_user.get('shortUuid'):
|
||||
panel_short_uuid_for_link = updated_panel_user.get(
|
||||
'shortUuid')
|
||||
return {
|
||||
"subscription_id": subscription_id_to_return,
|
||||
"end_date": final_end_date,
|
||||
"is_active": True,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": panel_short_uuid_for_link,
|
||||
"subscription_url": panel_actual_subscription_url
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error in activate_subscription (paid) for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def extend_subscription_for_referral (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,is_referee_bonus :bool =False )->Optional [datetime ]:
|
||||
reason ="referee bonus"if is_referee_bonus else "inviter referral bonus"
|
||||
return await self .extend_active_subscription_days (user_id ,bonus_days ,db_conn ,reason =reason )
|
||||
async def extend_active_subscription_days(
|
||||
self,
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
db_conn: aiosqlite.Connection,
|
||||
reason: str = "bonus") -> Optional[datetime]:
|
||||
db = db_conn
|
||||
try:
|
||||
user_cursor = await db.execute(
|
||||
"SELECT panel_user_uuid FROM users WHERE user_id = ?",
|
||||
(user_id, ))
|
||||
user_panel_data = await user_cursor.fetchone()
|
||||
if user_cursor: await user_cursor.close()
|
||||
panel_user_uuid_for_update = user_panel_data[
|
||||
'panel_user_uuid'] if user_panel_data and 'panel_user_uuid' in user_panel_data.keys(
|
||||
) and user_panel_data['panel_user_uuid'] else None
|
||||
|
||||
async def get_active_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]:
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
now_iso_utc =datetime .now (timezone .utc ).isoformat ()
|
||||
cursor =await db .execute ("""SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",(user_id ,now_iso_utc ))
|
||||
sub_row =await cursor .fetchone ();await cursor .close ()
|
||||
if sub_row :
|
||||
sub_dict =dict (sub_row )
|
||||
for date_key in ['start_date','end_date']:
|
||||
if sub_dict .get (date_key )and isinstance (sub_dict [date_key ],str ):
|
||||
try :sub_dict [date_key ]=datetime .fromisoformat (sub_dict [date_key ].replace ("Z","+00:00"))
|
||||
except ValueError :logging .warning (f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription")
|
||||
sql_select_active_sub = "SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 "
|
||||
params_select_active_sub: tuple = (user_id, )
|
||||
if panel_user_uuid_for_update:
|
||||
sql_select_active_sub += "AND panel_user_uuid = ? "
|
||||
params_select_active_sub += (panel_user_uuid_for_update, )
|
||||
else:
|
||||
logging.warning(
|
||||
f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical)."
|
||||
)
|
||||
sql_select_active_sub += "ORDER BY end_date DESC LIMIT 1"
|
||||
|
||||
cursor = await db.execute(sql_select_active_sub,
|
||||
params_select_active_sub)
|
||||
active_sub_row = await cursor.fetchone()
|
||||
if cursor: await cursor.close()
|
||||
|
||||
if not active_sub_row or not active_sub_row['end_date']:
|
||||
logging.info(
|
||||
f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus."
|
||||
)
|
||||
return None
|
||||
|
||||
current_end_date_str = active_sub_row['end_date']
|
||||
try:
|
||||
current_end_date = datetime.fromisoformat(
|
||||
current_end_date_str.replace("Z", "+00:00")) if isinstance(
|
||||
current_end_date_str, str) else current_end_date_str
|
||||
if not isinstance(current_end_date, datetime):
|
||||
raise ValueError(
|
||||
"current_end_date is not a datetime object after parsing"
|
||||
)
|
||||
except ValueError as ve:
|
||||
logging.error(
|
||||
f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}"
|
||||
)
|
||||
return None
|
||||
|
||||
if current_end_date.tzinfo is None:
|
||||
current_end_date = current_end_date.replace(
|
||||
tzinfo=timezone.utc)
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
|
||||
new_end_date = start_point_for_bonus + timedelta(days=bonus_days)
|
||||
|
||||
await db.execute(
|
||||
"UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",
|
||||
(new_end_date.isoformat(), active_sub_row['subscription_id']))
|
||||
logging.info(
|
||||
f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}"
|
||||
)
|
||||
|
||||
if panel_user_uuid_for_update:
|
||||
panel_update_payload = {
|
||||
"uuid":
|
||||
panel_user_uuid_for_update,
|
||||
"expireAt":
|
||||
new_end_date.isoformat(timespec='milliseconds').replace(
|
||||
'+00:00', 'Z')
|
||||
}
|
||||
if not await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid_for_update, panel_update_payload):
|
||||
logging.warning(
|
||||
f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus."
|
||||
)
|
||||
return new_end_date
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def extend_subscription_for_referral(
|
||||
self,
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
db_conn: aiosqlite.Connection,
|
||||
is_referee_bonus: bool = False) -> Optional[datetime]:
|
||||
reason = "referee bonus" if is_referee_bonus else "inviter referral bonus"
|
||||
return await self.extend_active_subscription_days(user_id,
|
||||
bonus_days,
|
||||
db_conn,
|
||||
reason=reason)
|
||||
|
||||
async def get_active_subscription(
|
||||
self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
db, should_manage = await self._get_db()
|
||||
try:
|
||||
now_iso_utc = datetime.now(timezone.utc).isoformat()
|
||||
cursor = await db.execute(
|
||||
"""SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",
|
||||
(user_id, now_iso_utc))
|
||||
sub_row = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
if sub_row:
|
||||
sub_dict = dict(sub_row)
|
||||
for date_key in ['start_date', 'end_date']:
|
||||
if sub_dict.get(date_key) and isinstance(
|
||||
sub_dict[date_key], str):
|
||||
try:
|
||||
sub_dict[date_key] = datetime.fromisoformat(
|
||||
sub_dict[date_key].replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription"
|
||||
)
|
||||
return sub_dict
|
||||
return None
|
||||
finally :await self ._release_db (db ,should_manage )
|
||||
finally:
|
||||
await self._release_db(db, should_manage)
|
||||
|
||||
async def get_subscriptions_ending_soon (self ,days_threshold :int )->List [Dict [str ,Any ]]:
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
now_utc =datetime .now (timezone .utc )
|
||||
threshold_date =now_utc +timedelta (days =days_threshold )
|
||||
today_date_str =now_utc .strftime ('%Y-%m-%d')
|
||||
query =""" SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """
|
||||
cursor =await db .execute (query ,(now_utc .isoformat (),threshold_date .isoformat (),today_date_str ));rows =await cursor .fetchall ();await cursor .close ()
|
||||
processed_rows =[]
|
||||
for row_data in rows :
|
||||
row_dict =dict (row_data );end_date_obj_for_calc =None
|
||||
if isinstance (row_dict ['end_date_raw'],str ):
|
||||
try :
|
||||
end_date_obj_for_calc =datetime .fromisoformat (row_dict ['end_date_raw'].replace ("Z","+00:00"))
|
||||
if end_date_obj_for_calc .tzinfo is None :end_date_obj_for_calc =end_date_obj_for_calc .replace (tzinfo =timezone .utc )
|
||||
row_dict ['days_left']=(end_date_obj_for_calc -now_utc ).total_seconds ()/(24 *3600 )
|
||||
except ValueError :row_dict ['days_left']=None
|
||||
else :row_dict ['days_left']=None
|
||||
processed_rows .append (row_dict )
|
||||
async def get_subscriptions_ending_soon(
|
||||
self, days_threshold: int) -> List[Dict[str, Any]]:
|
||||
db, should_manage = await self._get_db()
|
||||
try:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||
today_date_str = now_utc.strftime('%Y-%m-%d')
|
||||
query = """ SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """
|
||||
cursor = await db.execute(
|
||||
query, (now_utc.isoformat(), threshold_date.isoformat(),
|
||||
today_date_str))
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
processed_rows = []
|
||||
for row_data in rows:
|
||||
row_dict = dict(row_data)
|
||||
end_date_obj_for_calc = None
|
||||
if isinstance(row_dict['end_date_raw'], str):
|
||||
try:
|
||||
end_date_obj_for_calc = datetime.fromisoformat(
|
||||
row_dict['end_date_raw'].replace("Z", "+00:00"))
|
||||
if end_date_obj_for_calc.tzinfo is None:
|
||||
end_date_obj_for_calc = end_date_obj_for_calc.replace(
|
||||
tzinfo=timezone.utc)
|
||||
row_dict['days_left'] = (end_date_obj_for_calc -
|
||||
now_utc).total_seconds() / (
|
||||
24 * 3600)
|
||||
except ValueError:
|
||||
row_dict['days_left'] = None
|
||||
else:
|
||||
row_dict['days_left'] = None
|
||||
processed_rows.append(row_dict)
|
||||
return processed_rows
|
||||
finally :await self ._release_db (db ,should_manage )
|
||||
finally:
|
||||
await self._release_db(db, should_manage)
|
||||
|
||||
async def update_last_notification_sent (self ,user_id :int ,subscription_end_date_iso :str ):
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
today_iso_date_str =datetime .now (timezone .utc ).strftime ('%Y-%m-%d')
|
||||
await db .execute ("UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",(today_iso_date_str ,user_id ,subscription_end_date_iso ))
|
||||
await db .commit ()
|
||||
except Exception as e :logging .error (f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}");await db .rollback ()
|
||||
finally :await self ._release_db (db ,should_manage )
|
||||
async def update_last_notification_sent(self, user_id: int,
|
||||
subscription_end_date_iso: str):
|
||||
db, should_manage = await self._get_db()
|
||||
try:
|
||||
today_iso_date_str = datetime.now(
|
||||
timezone.utc).strftime('%Y-%m-%d')
|
||||
await db.execute(
|
||||
"UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",
|
||||
(today_iso_date_str, user_id, subscription_end_date_iso))
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}"
|
||||
)
|
||||
await db.rollback()
|
||||
finally:
|
||||
await self._release_db(db, should_manage)
|
||||
|
||||
Reference in New Issue
Block a user