diff --git a/core/bootstrap.py b/core/bootstrap.py
index fe672055..497e993b 100644
--- a/core/bootstrap.py
+++ b/core/bootstrap.py
@@ -1,10 +1,12 @@
from database import async_session_maker
+
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
-from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
+from .settings.management_config import MANAGEMENT_CONFIG, load_management_config, update_management_config
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
-from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
-from .settings.management_config import MANAGEGENT_CONFIG, load_management_config, update_management_config
+from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
+from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
+from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
async def bootstrap() -> None:
@@ -15,4 +17,5 @@ async def bootstrap() -> None:
await load_payments_config(session)
await load_money_config(session)
await load_management_config(session)
+ await load_tariffs_config(session)
await session.commit()
diff --git a/core/constants.py b/core/constants.py
index cf562812..ce50980e 100644
--- a/core/constants.py
+++ b/core/constants.py
@@ -1 +1 @@
-PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
\ No newline at end of file
+PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
diff --git a/core/settings/buttons_config.py b/core/settings/buttons_config.py
index eae5fae3..9feeb593 100644
--- a/core/settings/buttons_config.py
+++ b/core/settings/buttons_config.py
@@ -2,10 +2,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
+
from ..defaults import DEFAULT_BUTTONS_CONFIG
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
+BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
async def load_buttons_config(session: AsyncSession) -> None:
@@ -15,6 +17,7 @@ async def load_buttons_config(session: AsyncSession) -> None:
if setting is None:
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
+ buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
setting = Setting(
key="BUTTONS_CONFIG",
value=buttons_config,
@@ -25,6 +28,7 @@ async def load_buttons_config(session: AsyncSession) -> None:
stored = setting.value or {}
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(stored)
+ buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
setting.value = buttons_config
BUTTONS_CONFIG.clear()
@@ -47,10 +51,11 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo
else:
setting.value = new_values
- await session.flush()
+ await session.commit()
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(new_values)
+ buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config)
diff --git a/core/settings/management_config.py b/core/settings/management_config.py
index 945e6725..5cbb01f9 100644
--- a/core/settings/management_config.py
+++ b/core/settings/management_config.py
@@ -4,21 +4,23 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
+
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
-MANAGEGENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
+MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
+MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
async def load_management_config(session: AsyncSession) -> None:
- stmt = select(Setting).where(Setting.key == "management")
+ stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
setting = Setting(
- key="management",
+ key=MANAGEMENT_SETTING_KEY,
value=management_config,
description="Конфигурация управления ботом",
)
@@ -29,19 +31,19 @@ async def load_management_config(session: AsyncSession) -> None:
management_config.update(stored)
setting.value = management_config
- MANAGEGENT_CONFIG.clear()
- MANAGEGENT_CONFIG.update(management_config)
+ MANAGEMENT_CONFIG.clear()
+ MANAGEMENT_CONFIG.update(management_config)
await session.flush()
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
- stmt = select(Setting).where(Setting.key == "management")
+ stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
- key="management",
+ key=MANAGEMENT_SETTING_KEY,
value=new_values,
description="Конфигурация управления ботом",
)
@@ -49,11 +51,10 @@ async def update_management_config(session: AsyncSession, new_values: dict[str,
else:
setting.value = new_values
- await session.flush()
await session.commit()
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
management_config.update(new_values)
- MANAGEGENT_CONFIG.clear()
- MANAGEGENT_CONFIG.update(management_config)
+ MANAGEMENT_CONFIG.clear()
+ MANAGEMENT_CONFIG.update(management_config)
diff --git a/core/settings/modes_config.py b/core/settings/modes_config.py
index 239542fb..1aa87e54 100644
--- a/core/settings/modes_config.py
+++ b/core/settings/modes_config.py
@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
+
from ..defaults import DEFAULT_MODES_CONFIG
@@ -47,7 +48,7 @@ async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]
else:
setting.value = new_values
- await session.flush()
+ await session.commit()
modes_config = DEFAULT_MODES_CONFIG.copy()
modes_config.update(new_values)
diff --git a/core/settings/money_config.py b/core/settings/money_config.py
index feece227..0071f805 100644
--- a/core/settings/money_config.py
+++ b/core/settings/money_config.py
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
+
from ..defaults import DEFAULT_MONEY_CONFIG
@@ -49,7 +50,7 @@ async def update_money_config(session: AsyncSession, new_values: dict[str, Any])
else:
setting.value = new_values
- await session.flush()
+ await session.commit()
money_config = DEFAULT_MONEY_CONFIG.copy()
money_config.update(new_values)
diff --git a/core/settings/notifications_config.py b/core/settings/notifications_config.py
index 3e87cda7..21685e7c 100644
--- a/core/settings/notifications_config.py
+++ b/core/settings/notifications_config.py
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
+
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
@@ -49,7 +50,7 @@ async def update_notifications_config(session: AsyncSession, new_values: dict[st
else:
setting.value = new_values
- await session.flush()
+ await session.commit()
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
notifications_config.update(new_values)
diff --git a/core/settings/payments_config.py b/core/settings/payments_config.py
index a92181f3..98099663 100644
--- a/core/settings/payments_config.py
+++ b/core/settings/payments_config.py
@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
+
from ..defaults import DEFAULT_PAYMENTS_CONFIG
@@ -47,7 +48,7 @@ async def update_payments_config(session: AsyncSession, new_values: dict[str, bo
else:
setting.value = new_values
- await session.flush()
+ await session.commit()
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
payments_config.update(new_values)
diff --git a/core/settings/tariffs_config.py b/core/settings/tariffs_config.py
new file mode 100644
index 00000000..cf338458
--- /dev/null
+++ b/core/settings/tariffs_config.py
@@ -0,0 +1,192 @@
+from math import ceil
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database.models import Setting
+
+
+TARIFFS_CONFIG: dict[str, Any] = {
+ "ALLOW_DOWNGRADE": True,
+ "KEY_ADDONS_PACK_MODE": "all",
+}
+
+
+async def load_tariffs_config(session: AsyncSession) -> None:
+ """Загружает конфиг тарифов из БД."""
+ stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
+ result = await session.execute(stmt)
+ setting = result.scalar_one_or_none()
+
+ if setting is None:
+ tariffs_config = TARIFFS_CONFIG.copy()
+ setting = Setting(
+ key="TARIFFS_CONFIG",
+ value=tariffs_config,
+ description="Конфигурация тарифов",
+ )
+ session.add(setting)
+ else:
+ stored = setting.value or {}
+ tariffs_config = TARIFFS_CONFIG.copy()
+ tariffs_config.update(stored)
+ setting.value = tariffs_config
+
+ TARIFFS_CONFIG.clear()
+ TARIFFS_CONFIG.update(tariffs_config)
+ await session.flush()
+
+
+async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
+ """Обновляет конфиг тарифов."""
+ stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
+ result = await session.execute(stmt)
+ setting = result.scalar_one_or_none()
+
+ if setting is None:
+ setting = Setting(
+ key="TARIFFS_CONFIG",
+ value=new_values,
+ description="Конфигурация тарифов",
+ )
+ session.add(setting)
+ else:
+ setting.value = new_values
+
+ await session.commit()
+
+ tariffs_config = TARIFFS_CONFIG.copy()
+ tariffs_config.update(new_values)
+
+ TARIFFS_CONFIG.clear()
+ TARIFFS_CONFIG.update(tariffs_config)
+
+
+def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
+ base_device_limit = int(tariff.get("device_limit") or 1)
+ extra_devices = max(0, device_limit - base_device_limit)
+ if extra_devices <= 0:
+ return 0
+
+ step_price = int(tariff.get("device_step_rub") or 0)
+ overrides = tariff.get("device_overrides") or {}
+
+ override_total = overrides.get(str(device_limit))
+ if override_total is not None:
+ return int(ceil(float(override_total)))
+
+ return int(ceil(extra_devices * step_price))
+
+
+def calc_extra_traffic_price(tariff: dict[str, Any], traffic_gb: int | None) -> int:
+ if traffic_gb is None:
+ return 0
+
+ traffic_limit_bytes = tariff.get("traffic_limit")
+ if traffic_limit_bytes:
+ base_traffic_gb = ceil(traffic_limit_bytes / 1024 / 1024 / 1024)
+ else:
+ base_traffic_gb = 0
+
+ step_price = int(tariff.get("traffic_step_rub") or 0)
+ overrides = tariff.get("traffic_overrides") or {}
+
+ override_total = overrides.get(str(traffic_gb))
+ if override_total is not None:
+ return int(ceil(float(override_total)))
+
+ if traffic_gb == 0:
+ return 0
+
+ extra_gb = max(0, traffic_gb - base_traffic_gb)
+ if extra_gb <= 0:
+ return 0
+
+ return int(ceil(extra_gb * step_price))
+
+
+def calculate_config_price(
+ tariff: dict[str, Any],
+ duration_days: int,
+ device_limit: int,
+ traffic_gb: int | None,
+) -> int:
+ base_duration = int(tariff.get("duration_days") or 0) or duration_days or 30
+ if base_duration <= 0:
+ base_duration = duration_days or 30
+
+ base_price = int(tariff.get("price_rub") or 0)
+
+ duration_multiplier = duration_days / base_duration
+ base_price_scaled = base_price * duration_multiplier
+
+ extra_devices_price = calc_extra_devices_price(tariff, device_limit)
+ extra_traffic_price = calc_extra_traffic_price(tariff, traffic_gb)
+
+ total = base_price_scaled + extra_devices_price + extra_traffic_price
+ return int(ceil(total))
+
+
+def normalize_tariff_config(tariff: dict[str, Any]) -> dict[str, Any]:
+ raw_duration_options = tariff.get("duration_options") or []
+ duration_options: list[int] = []
+ for value in raw_duration_options:
+ try:
+ v = int(value)
+ except (TypeError, ValueError):
+ continue
+ if v > 0:
+ duration_options.append(v)
+ if not duration_options:
+ base_duration = int(tariff.get("duration_days") or 0) or 30
+ duration_options = [base_duration]
+ duration_options = sorted(set(duration_options))
+
+ raw_device_options = tariff.get("device_options") or []
+ device_options: list[int] = []
+ for value in raw_device_options:
+ try:
+ v = int(value)
+ except (TypeError, ValueError):
+ continue
+ if v > 0:
+ device_options.append(v)
+ if not device_options:
+ base_device_limit = int(tariff.get("device_limit") or 0)
+ if base_device_limit > 0:
+ device_options = [base_device_limit]
+ else:
+ device_options = []
+ device_options = sorted(set(device_options))
+
+ raw_traffic_options = tariff.get("traffic_options_gb")
+ traffic_options_gb: list[int] | None
+ if raw_traffic_options is None:
+ traffic_options_gb = None
+ else:
+ traffic_values: list[int] = []
+ has_unlimited = False
+ for value in raw_traffic_options:
+ try:
+ v = int(value)
+ except (TypeError, ValueError):
+ continue
+ if v == 0:
+ has_unlimited = True
+ elif v > 0:
+ traffic_values.append(v)
+ if not traffic_values and not has_unlimited:
+ traffic_options_gb = None
+ else:
+ unique_values = sorted(set(traffic_values))
+ if has_unlimited:
+ traffic_options_gb = [0] + unique_values
+ else:
+ traffic_options_gb = unique_values
+
+ return {
+ "duration_options": duration_options,
+ "device_options": device_options,
+ "traffic_options_gb": traffic_options_gb,
+ }
diff --git a/database/hot_leads.py b/database/hot_leads.py
index a68e634e..32b19ff2 100644
--- a/database/hot_leads.py
+++ b/database/hot_leads.py
@@ -1,8 +1,8 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from database.models import Key, Payment, User
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
+from database.models import Key, Payment, User
async def get_hot_leads(session: AsyncSession):
diff --git a/database/keys.py b/database/keys.py
index bf432c38..2e276de4 100644
--- a/database/keys.py
+++ b/database/keys.py
@@ -17,29 +17,49 @@ async def store_key(
key: str,
server_id: str,
remnawave_link: str = None,
- tariff_id: int = None,
- alias: str = None,
+ tariff_id: int | None = None,
+ alias: str | None = None,
+ selected_device_limit: int | None = None,
+ selected_traffic_limit: int | None = None,
+ selected_price_rub: int | None = None,
+ current_device_limit: int | None = None,
+ current_traffic_limit: int | None = None,
):
+ """Сохраняет или обновляет ключ подписки."""
try:
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
existing_key = exists.scalar_one_or_none()
if existing_key:
- await session.execute(
- update(Key)
- .where(Key.tg_id == tg_id, Key.client_id == client_id)
- .values(
- email=email,
- expiry_time=expiry_time,
- key=key,
- server_id=server_id,
- remnawave_link=remnawave_link,
- tariff_id=tariff_id,
- alias=alias,
- )
- )
+ values: dict = {
+ "email": email,
+ "expiry_time": expiry_time,
+ "key": key,
+ "server_id": server_id,
+ "remnawave_link": remnawave_link,
+ "tariff_id": tariff_id,
+ "alias": alias,
+ }
+
+ if selected_device_limit is not None:
+ values["selected_device_limit"] = selected_device_limit
+ if selected_traffic_limit is not None:
+ values["selected_traffic_limit"] = selected_traffic_limit
+ if selected_price_rub is not None:
+ values["selected_price_rub"] = selected_price_rub
+ if current_device_limit is not None:
+ values["current_device_limit"] = current_device_limit
+ if current_traffic_limit is not None:
+ values["current_traffic_limit"] = current_traffic_limit
+
+ await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else:
+ if current_device_limit is None:
+ current_device_limit = selected_device_limit
+ if current_traffic_limit is None:
+ current_traffic_limit = selected_traffic_limit
+
new_key = Key(
tg_id=tg_id,
client_id=client_id,
@@ -51,6 +71,11 @@ async def store_key(
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
+ selected_device_limit=selected_device_limit,
+ selected_traffic_limit=selected_traffic_limit,
+ selected_price_rub=selected_price_rub,
+ current_device_limit=current_device_limit,
+ current_traffic_limit=current_traffic_limit,
)
session.add(new_key)
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
@@ -79,6 +104,7 @@ async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
+ """Возвращает подробную информацию о ключе по email."""
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
result = await session.execute(stmt)
row = result.first()
@@ -116,6 +142,11 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
"cluster_name": key.server_id,
"location_name": key.server_id,
"tariff_id": key.tariff_id,
+ "selected_device_limit": key.selected_device_limit,
+ "selected_traffic_limit": key.selected_traffic_limit,
+ "selected_price_rub": key.selected_price_rub,
+ "current_device_limit": key.current_device_limit,
+ "current_traffic_limit": key.current_traffic_limit,
}
@@ -156,13 +187,18 @@ async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str,
is_frozen = TRUE
WHERE tg_id = :tg_id
AND client_id = :client_id
- """
+ """
),
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
)
-async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int):
+async def mark_key_as_unfrozen(
+ session: AsyncSession,
+ tg_id: int,
+ client_id: str,
+ new_expiry_time: int,
+):
await session.execute(
text(
"""
@@ -171,7 +207,7 @@ async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str
is_frozen = FALSE
WHERE tg_id = :tg_id
AND client_id = :client_id
- """
+ """
),
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
)
@@ -199,3 +235,53 @@ async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
res = await session.execute(q)
await session.commit()
return res.scalar_one_or_none() is not None
+
+
+async def save_key_config_with_mode(
+ session: AsyncSession,
+ email: str,
+ selected_devices: int | None,
+ selected_traffic_gb: int | None,
+ total_price: int,
+ has_device_choice: bool,
+ has_traffic_choice: bool,
+ config_mode: str,
+) -> None:
+ values: dict = {}
+
+ if config_mode == "pack":
+ if has_device_choice and selected_devices is not None:
+ values["current_device_limit"] = int(selected_devices)
+ if has_traffic_choice and selected_traffic_gb is not None:
+ values["current_traffic_limit"] = int(selected_traffic_gb)
+ else:
+ device_val = int(selected_devices) if selected_devices is not None and has_device_choice else None
+ traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_choice else None
+
+ values["selected_device_limit"] = device_val
+ values["selected_traffic_limit"] = traffic_val
+ values["selected_price_rub"] = int(total_price)
+ values["current_device_limit"] = device_val
+ values["current_traffic_limit"] = traffic_val
+
+ if not values:
+ return
+
+ await session.execute(update(Key).where(Key.email == email).values(**values))
+
+
+async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
+ """Сбрасывает текущие лимиты к выбранным для ключа."""
+ await session.execute(
+ text(
+ """
+ UPDATE keys
+ SET current_device_limit = selected_device_limit,
+ current_traffic_limit = selected_traffic_limit
+ WHERE client_id = :client_id
+ """
+ ),
+ {"client_id": client_id},
+ )
+ await session.commit()
+ logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
diff --git a/database/models.py b/database/models.py
index 6cce7e9b..600fe5ba 100644
--- a/database/models.py
+++ b/database/models.py
@@ -77,6 +77,13 @@ class Key(DictLikeMixin, Base):
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
+ selected_device_limit = Column(Integer, nullable=True)
+ selected_traffic_limit = Column(BigInteger, nullable=True)
+ selected_price_rub = Column(Integer, nullable=True)
+
+ current_device_limit = Column(Integer, nullable=True)
+ current_traffic_limit = Column(BigInteger, nullable=True)
+
class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs"
@@ -95,6 +102,17 @@ class Tariff(DictLikeMixin, Base):
sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False)
+ configurable = Column(Boolean, nullable=False, server_default="false")
+
+ device_options = Column(JSONB, nullable=True)
+ traffic_options_gb = Column(JSONB, nullable=True)
+
+ device_step_rub = Column(Integer, nullable=True)
+ device_overrides = Column(JSONB, nullable=True)
+
+ traffic_step_rub = Column(Integer, nullable=True)
+ traffic_overrides = Column(JSONB, nullable=True)
+
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
diff --git a/database/referrals.py b/database/referrals.py
index 37018c47..6dd0e00c 100644
--- a/database/referrals.py
+++ b/database/referrals.py
@@ -3,6 +3,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
+from core.bootstrap import BUTTONS_CONFIG
from database.models import Referral
from logger import logger
@@ -57,6 +58,11 @@ async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int
async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> float:
+ referral_enabled = bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", True))
+ if not referral_enabled:
+ logger.debug("Реферальная программа отключена, бонусы не начисляются")
+ return 0.0
+
if CHECK_REFERRAL_REWARD_ISSUED:
bonus_cte = """
WITH RECURSIVE
diff --git a/database/statistics.py b/database/statistics.py
index 8b18db07..cfc75ab9 100644
--- a/database/statistics.py
+++ b/database/statistics.py
@@ -3,8 +3,8 @@ from datetime import date, datetime
from sqlalchemy import and_, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
-from database.models import Key, Payment, Referral, Tariff, User
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
+from database.models import Key, Payment, Referral, Tariff, User
async def count_total_users(session: AsyncSession) -> int:
diff --git a/database/tracking_sources.py b/database/tracking_sources.py
index 1bd34abe..37b53bad 100644
--- a/database/tracking_sources.py
+++ b/database/tracking_sources.py
@@ -2,9 +2,9 @@ from sqlalchemy import and_, func, insert, not_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
+from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Payment, TrackingSource, User
from logger import logger
-from core.constants import PAYMENT_SYSTEMS_EXCLUDED
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 9350bbcd..2fe2c737 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -13,6 +13,7 @@ from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
+from .tariffs import router as tariff_router
router = Router(name="handlers_main_router")
@@ -29,4 +30,5 @@ router.include_routers(
instructions_router,
admin_router,
refferal_router,
+ tariff_router,
)
diff --git a/handlers/admin/clusters/cluster_tariffs.py b/handlers/admin/clusters/cluster_tariffs.py
index da58442e..48985e54 100644
--- a/handlers/admin/clusters/cluster_tariffs.py
+++ b/handlers/admin/clusters/cluster_tariffs.py
@@ -59,11 +59,7 @@ async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminCluste
group_code = row["group_code"]
- await session.execute(
- update(Server)
- .where(Server.cluster_name == cluster_name)
- .values(tariff_group=group_code)
- )
+ await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
diff --git a/handlers/admin/management/keyboard.py b/handlers/admin/management/keyboard.py
index 6cb81120..0c2f4578 100644
--- a/handlers/admin/management/keyboard.py
+++ b/handlers/admin/management/keyboard.py
@@ -1,7 +1,8 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
-from core.bootstrap import MANAGEGENT_CONFIG
+from core.bootstrap import MANAGEMENT_CONFIG
+
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
@@ -39,7 +40,7 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action="upload_file").pack(),
)
- maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False))
+ maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))
maintenance_text = "🛠️ Выключить тех. работы" if maintenance_enabled else "🛠️ Включить тех. работы"
builder.button(
text=maintenance_text,
diff --git a/handlers/admin/management/maintenance.py b/handlers/admin/management/maintenance.py
index 898fea3b..1d374553 100644
--- a/handlers/admin/management/maintenance.py
+++ b/handlers/admin/management/maintenance.py
@@ -3,7 +3,7 @@ from aiogram.types import CallbackQuery
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from core.bootstrap import MANAGEGENT_CONFIG, update_management_config
+from core.bootstrap import MANAGEMENT_CONFIG, update_management_config
from database.models import Admin
from filters.admin import IsAdminFilter
@@ -39,7 +39,7 @@ async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession
await callback.answer("❌ Админ не найден.", show_alert=True)
return
- current_config = dict(MANAGEGENT_CONFIG)
+ current_config = dict(MANAGEMENT_CONFIG)
current_value = bool(current_config.get("MAINTENANCE_ENABLED", False))
new_value = not current_value
current_config["MAINTENANCE_ENABLED"] = new_value
diff --git a/handlers/admin/sender/__init__.py b/handlers/admin/sender/__init__.py
index 9d9b9023..a05476e0 100644
--- a/handlers/admin/sender/__init__.py
+++ b/handlers/admin/sender/__init__.py
@@ -1,3 +1,4 @@
from .sender_handler import router
+
__all__ = ["router"]
diff --git a/handlers/admin/sender/sender_handler.py b/handlers/admin/sender/sender_handler.py
index 57a2223c..f80048fa 100644
--- a/handlers/admin/sender/sender_handler.py
+++ b/handlers/admin/sender/sender_handler.py
@@ -11,8 +11,8 @@ from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
-from .sender_states import AdminSender
from .sender_service import BroadcastService
+from .sender_states import AdminSender
from .sender_utils import get_recipients, parse_message_buttons
@@ -54,11 +54,7 @@ async def handle_cluster_select(callback_query: CallbackQuery, session: AsyncSes
AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(),
)
-async def handle_broadcast_type(
- callback_query: CallbackQuery,
- callback_data: AdminSenderCallback,
- state: FSMContext
-):
+async def handle_broadcast_type(callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext):
await callback_query.message.edit_text(
text=(
"✍️ Введите текст сообщения для рассылки\n\n"
@@ -92,8 +88,7 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
max_len = 1024 if photo else 4096
if len(clean_text) > max_len:
await message.answer(
- f"⚠️ Сообщение слишком длинное.\n"
- f"Максимум: {max_len} символов, сейчас: {len(clean_text)}.",
+ f"⚠️ Сообщение слишком длинное.\nМаксимум: {max_len} символов, сейчас: {len(clean_text)}.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
@@ -119,42 +114,21 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
await state.clear()
return
- await state.update_data(
- text=clean_text,
- photo=photo,
- keyboard=keyboard.model_dump() if keyboard else None
- )
+ await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
await state.set_state(AdminSender.preview)
if photo:
- await message.answer_photo(
- photo=photo,
- caption=clean_text,
- parse_mode="HTML",
- reply_markup=keyboard
- )
+ await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
else:
- await message.answer(
- text=clean_text,
- parse_mode="HTML",
- reply_markup=keyboard
- )
+ await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
await message.answer(
- f"👀 Это предпросмотр рассылки.\n"
- f"👥 Количество получателей: {user_count}\n\n"
- f"Отправить?",
+ f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: {user_count}\n\nОтправить?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
- InlineKeyboardButton(
- text="📤 Отправить",
- callback_data="send_broadcast"
- ),
- InlineKeyboardButton(
- text="❌ Отмена",
- callback_data="cancel_broadcast"
- ),
+ InlineKeyboardButton(text="📤 Отправить", callback_data="send_broadcast"),
+ InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_broadcast"),
]
]
),
@@ -162,11 +136,7 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
@router.callback_query(F.data == "send_broadcast", IsAdminFilter())
-async def handle_broadcast_confirm(
- callback_query: CallbackQuery,
- state: FSMContext,
- session: AsyncSession
-):
+async def handle_broadcast_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
data = await state.get_data()
text_message = data.get("text")
photo = data.get("photo")
@@ -200,35 +170,21 @@ async def handle_broadcast_confirm(
await state.clear()
return
- await callback_query.message.edit_text(
- f"📤 Рассылка начата!\n"
- f"👥 Количество получателей: {total_users}"
- )
+ await callback_query.message.edit_text(f"📤 Рассылка начата!\n👥 Количество получателей: {total_users}")
messages = []
for tg_id in tg_ids:
- message_data = {
- "tg_id": tg_id,
- "text": text_message,
- "photo": photo,
- "keyboard": keyboard
- }
+ message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
- broadcast_service = BroadcastService(
- bot=callback_query.bot,
- session=session,
- messages_per_second=35
- )
-
+ broadcast_service = BroadcastService(bot=callback_query.bot, session=session, messages_per_second=35)
+
stats = await broadcast_service.broadcast(messages, workers=5)
duration_minutes = int(stats["total_duration"] // 60)
duration_seconds = int(stats["total_duration"] % 60)
duration_str = (
- f"{duration_minutes} мин {duration_seconds} сек"
- if duration_minutes > 0
- else f"{duration_seconds} сек"
+ f"{duration_minutes} мин {duration_seconds} сек" if duration_minutes > 0 else f"{duration_seconds} сек"
)
await callback_query.message.answer(
diff --git a/handlers/admin/sender/sender_service.py b/handlers/admin/sender/sender_service.py
index c3bdb8ac..d39d7fb1 100644
--- a/handlers/admin/sender/sender_service.py
+++ b/handlers/admin/sender/sender_service.py
@@ -12,7 +12,7 @@ from logger import logger
class BroadcastMessage:
- def __init__(self, tg_id: int, text: str, photo: str | None = None, keyboard: Any = None):
+ def __init__(self, tg_id: int, text: str, photo: str | None = None, keyboard: Any = None) -> None:
self.tg_id = tg_id
self.text = text
self.photo = photo
@@ -22,17 +22,17 @@ class BroadcastMessage:
class RateLimiter:
- def __init__(self, max_rate: int = 35, window: float = 1.0):
+ def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
self.max_rate = max_rate
self.window = window
self.send_times = deque()
self.lock = asyncio.Lock()
-
+
def _clean_old_timestamps(self, current_time: float):
cutoff_time = current_time - self.window
while self.send_times and self.send_times[0] <= cutoff_time:
self.send_times.popleft()
-
+
async def acquire(self):
async with self.lock:
while True:
@@ -46,14 +46,13 @@ class RateLimiter:
oldest_timestamp = self.send_times[0]
time_to_wait = (oldest_timestamp + self.window) - now
-
+
if time_to_wait > 0:
await asyncio.sleep(time_to_wait + 0.001)
class BroadcastService:
-
- def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35):
+ def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None:
self.bot = bot
self.session = session
self.rate_limiter = RateLimiter(max_rate=messages_per_second)
@@ -64,44 +63,36 @@ class BroadcastService:
self.total_sent = 0
self.start_time = None
self.is_running = False
-
+
async def _send_single_message(self, msg: BroadcastMessage) -> bool:
try:
await self.rate_limiter.acquire()
-
+
if msg.photo:
await self.bot.send_photo(
- chat_id=msg.tg_id,
- photo=msg.photo,
- caption=msg.text,
- parse_mode="HTML",
- reply_markup=msg.keyboard
+ chat_id=msg.tg_id, photo=msg.photo, caption=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
)
else:
await self.bot.send_message(
- chat_id=msg.tg_id,
- text=msg.text,
- parse_mode="HTML",
- reply_markup=msg.keyboard
+ chat_id=msg.tg_id, text=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
)
-
+
return True
-
+
except TelegramRetryAfter as e:
msg.retry_after = e.retry_after
msg.attempts += 1
logger.warning(
- f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. "
- f"(попытка {msg.attempts})"
+ f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. (попытка {msg.attempts})"
)
await self.delayed_queue.put(msg)
return False
-
+
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {msg.tg_id}")
self.blocked_users.add(msg.tg_id)
return False
-
+
except TelegramBadRequest as e:
error_msg = str(e).lower()
if "chat not found" in error_msg:
@@ -110,17 +101,17 @@ class BroadcastService:
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {msg.tg_id}: {e}")
return False
-
+
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения пользователю {msg.tg_id}: {e}")
return False
-
+
async def _process_delayed_messages(self):
while self.is_running:
try:
if not self.delayed_queue.empty():
msg = await asyncio.wait_for(self.delayed_queue.get(), timeout=0.1)
-
+
if msg.retry_after:
await asyncio.sleep(msg.retry_after)
msg.retry_after = None
@@ -132,53 +123,52 @@ class BroadcastService:
self.results.append(False)
else:
await asyncio.sleep(0.1)
-
- except asyncio.TimeoutError:
+
+ except TimeoutError:
continue
except Exception as e:
logger.error(f"❌ Ошибка в обработчике отложенных сообщений: {e}")
await asyncio.sleep(0.1)
-
+
async def _worker(self):
while self.is_running:
try:
msg = await asyncio.wait_for(self.queue.get(), timeout=0.1)
success = await self._send_single_message(msg)
-
+
if success:
self.total_sent += 1
self.results.append(True)
elif msg.attempts == 0:
self.results.append(False)
-
+
self.queue.task_done()
-
- except asyncio.TimeoutError:
+
+ except TimeoutError:
continue
except Exception as e:
logger.error(f"❌ Ошибка в воркере рассылки: {e}")
await asyncio.sleep(0.1)
-
+
async def _save_blocked_users(self):
if not self.blocked_users:
return
-
+
try:
from sqlalchemy.dialects.postgresql import insert
+
from database.models import BlockedUser
-
+
values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
- stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(
- index_elements=[BlockedUser.tg_id]
- )
+ stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
await self.session.execute(stmt)
await self.session.commit()
logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users")
except Exception as e:
logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}")
await self.session.rollback()
-
+
async def broadcast(self, messages: list[dict], workers: int = 20) -> dict:
self.is_running = True
self.start_time = time.time()
@@ -191,10 +181,10 @@ class BroadcastService:
tg_id=msg_data["tg_id"],
text=msg_data["text"],
photo=msg_data.get("photo"),
- keyboard=msg_data.get("keyboard")
+ keyboard=msg_data.get("keyboard"),
)
await self.queue.put(msg)
-
+
logger.info(f"📤 Начата рассылка на {len(messages)} пользователей с {workers} воркерами")
worker_tasks = [asyncio.create_task(self._worker()) for _ in range(workers)]
@@ -221,7 +211,7 @@ class BroadcastService:
total_duration = end_time - self.start_time
success_count = sum(1 for r in self.results if r)
avg_speed = self.total_sent / total_duration if total_duration > 0 else 0
-
+
stats = {
"total_duration": total_duration,
"total_sent": self.total_sent,
@@ -229,13 +219,12 @@ class BroadcastService:
"failed_count": len(self.results) - success_count,
"avg_speed": avg_speed,
"total_messages": len(messages),
- "blocked_users": len(self.blocked_users)
+ "blocked_users": len(self.blocked_users),
}
-
+
logger.info(
f"✅ Рассылка завершена: {success_count}/{len(messages)} успешно, "
f"скорость: {avg_speed:.1f} сообщений/сек, время: {total_duration:.1f} сек"
)
-
- return stats
+ return stats
diff --git a/handlers/admin/sender/sender_states.py b/handlers/admin/sender/sender_states.py
index 0fed8754..c16b1594 100644
--- a/handlers/admin/sender/sender_states.py
+++ b/handlers/admin/sender/sender_states.py
@@ -4,4 +4,3 @@ from aiogram.fsm.state import State, StatesGroup
class AdminSender(StatesGroup):
waiting_for_message = State()
preview = State()
-
diff --git a/handlers/admin/sender/sender_utils.py b/handlers/admin/sender/sender_utils.py
index 47a0461f..287e3a16 100644
--- a/handlers/admin/sender/sender_utils.py
+++ b/handlers/admin/sender/sender_utils.py
@@ -7,33 +7,24 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import distinct, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
from logger import logger
-from core.constants import PAYMENT_SYSTEMS_EXCLUDED
-async def get_recipients(
- session: AsyncSession,
- send_to: str,
- cluster_name: str | None = None
-) -> tuple[list[int], int]:
+async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str | None = None) -> tuple[list[int], int]:
now_ms = int(datetime.utcnow().timestamp() * 1000)
banned_tg_ids = select(BlockedUser.tg_id).union_all(
- select(ManualBan.tg_id).where(
- (ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow())
- )
+ select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
)
query = None
-
+
if send_to == "subscribed":
query = (
- select(distinct(User.tg_id))
- .join(Key)
- .where(Key.expiry_time > now_ms)
- .where(~User.tg_id.in_(banned_tg_ids))
+ select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
)
-
+
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
@@ -48,7 +39,7 @@ async def get_recipients(
)
)
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
-
+
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = (
@@ -56,7 +47,7 @@ async def get_recipients(
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
.where(~User.tg_id.in_(banned_tg_ids))
)
-
+
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
@@ -65,7 +56,7 @@ async def get_recipients(
.where(Server.cluster_name == cluster_name)
.where(~User.tg_id.in_(banned_tg_ids))
)
-
+
elif send_to == "hotleads":
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
query = (
@@ -77,7 +68,7 @@ async def get_recipients(
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
.where(~User.tg_id.in_(banned_tg_ids))
)
-
+
elif send_to == "trial":
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
query = (
@@ -85,7 +76,7 @@ async def get_recipients(
.where(Key.tariff_id.in_(trial_tariff_subquery))
.where(~Key.tg_id.in_(banned_tg_ids))
)
-
+
else:
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
@@ -96,22 +87,22 @@ async def get_recipients(
def strip_html_tags(text: str) -> str:
text = re.sub(r'0 можно использовать как безлимит по устройствам.")
+ lines.append("Нажмите на вариант, чтобы задать доплату в рублях.")
+ lines.append("Отправьте 0, чтобы вернуть расчёт по базовому шагу.")
+ lines.append("")
+ lines.append("Текущие значения:")
+
+ for devices in sorted(device_options):
+ key = str(devices)
+ formula_extra = calculate_device_formula_extra(tariff, devices)
+ override_extra = overrides.get(key)
+ if override_extra is not None:
+ effective_extra = int(override_extra)
+ status = " (индивидуальная доплата)"
+ else:
+ effective_extra = formula_extra
+ status = ""
+ if devices == 0:
+ label = "безлимит устройств"
+ else:
+ label = f"{devices} устр."
+ lines.append(f"• {label}: доплата {effective_extra}₽{status}")
+
+ text = "\n".join(lines)
+
+ rows: list[list[InlineKeyboardButton]] = []
+ for devices in sorted(device_options):
+ key = str(devices)
+ formula_extra = calculate_device_formula_extra(tariff, devices)
+ override_extra = overrides.get(key)
+ if override_extra is not None:
+ effective_extra = int(override_extra)
+ status = "★"
+ else:
+ effective_extra = formula_extra
+ status = ""
+ if devices == 0:
+ name = "безлимит устройств"
+ else:
+ name = f"{devices} устр."
+ if effective_extra > 0:
+ label = f"{status} {name} — доплата +{effective_extra}₽"
+ else:
+ label = f"{status} {name} — без доплаты"
+ label = label.strip()
+ rows.append([
+ InlineKeyboardButton(
+ text=label,
+ callback_data=f"cfg_dev_over_item|{tariff_id}|{devices}",
+ )
+ ])
+
+ rows.append([
+ InlineKeyboardButton(
+ text="🧹 Сбросить все индивидуальные доплаты",
+ callback_data=f"cfg_dev_over_clear|{tariff_id}",
+ )
+ ])
+ rows.append([
+ InlineKeyboardButton(
+ text="⬅️ Назад к конфигуратору",
+ callback_data=f"edit_config|{tariff_id}",
+ )
+ ])
+
+ markup = InlineKeyboardMarkup(inline_keyboard=rows)
+ return text, markup
+
+
+def build_traffic_overrides_screen(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
+ tariff_id = tariff.id
+ base_price = int(tariff.price_rub or 0)
+ traffic_options = tariff.traffic_options_gb or []
+ overrides = getattr(tariff, "traffic_overrides", None) or {}
+
+ all_options = sorted(set(traffic_options + [0]))
+
+ lines: list[str] = []
+ lines.append("📊 Доплаты по вариантам трафика.")
+ lines.append("")
+ lines.append(f"Базовая цена тарифа: {base_price}₽")
+ lines.append("Ниже показаны варианты лимитов и текущая доплата.")
+ lines.append("Значение 0 — безлимитный трафик.")
+ lines.append("Нажмите на вариант, чтобы задать доплату в рублях.")
+ lines.append("Отправьте 0, чтобы вернуть расчёт по базовому шагу.")
+ lines.append("")
+ lines.append("Текущие значения:")
+
+ for gb in all_options:
+ key = str(gb)
+ formula_extra = calculate_traffic_formula_extra(tariff, gb)
+ override_extra = overrides.get(key)
+ if override_extra is not None:
+ effective_extra = int(override_extra)
+ status = " (индивидуальная доплата)"
+ else:
+ effective_extra = formula_extra
+ status = ""
+ if gb == 0:
+ label = "безлимит"
+ else:
+ label = f"{gb} ГБ"
+ lines.append(f"• {label}: доплата {effective_extra}₽{status}")
+
+ text = "\n".join(lines)
+
+ rows: list[list[InlineKeyboardButton]] = []
+ for gb in all_options:
+ key = str(gb)
+ formula_extra = calculate_traffic_formula_extra(tariff, gb)
+ override_extra = overrides.get(key)
+ if override_extra is not None:
+ effective_extra = int(override_extra)
+ status = "★"
+ else:
+ effective_extra = formula_extra
+ status = ""
+ if gb == 0:
+ name = "безлимит"
+ else:
+ name = f"{gb} ГБ"
+ if effective_extra > 0:
+ label = f"{status} {name} — доплата +{effective_extra}₽"
+ else:
+ label = f"{status} {name} — без доплаты"
+ label = label.strip()
+ rows.append([
+ InlineKeyboardButton(
+ text=label,
+ callback_data=f"cfg_trf_over_item|{tariff_id}|{gb}",
+ )
+ ])
+
+ rows.append([
+ InlineKeyboardButton(
+ text="🧹 Сбросить все индивидуальные доплаты",
+ callback_data=f"cfg_trf_over_clear|{tariff_id}",
+ )
+ ])
+ rows.append([
+ InlineKeyboardButton(
+ text="⬅️ Назад к конфигуратору",
+ callback_data=f"edit_config|{tariff_id}",
+ )
+ ])
+
+ markup = InlineKeyboardMarkup(inline_keyboard=rows)
+ return text, markup
+
+
+def build_config_summary_text(tariff: Tariff) -> str:
+ cfg = normalize_tariff_config(tariff.to_dict())
+ configurable_text = "включен" if getattr(tariff, "configurable", False) else "выключен"
+
+ base_duration = tariff.duration_days
+ base_devices = tariff.device_limit if tariff.device_limit is not None else "—"
+ if tariff.traffic_limit is None:
+ base_traffic_text = "безлимит"
+ else:
+ base_traffic_text = f"{tariff.traffic_limit} ГБ"
+ base_price = tariff.price_rub or 0
+
+ device_options = cfg.get("device_options") or []
+ traffic_options_gb = cfg.get("traffic_options_gb")
+
+ duration_line = f"📅 Длительность: фиксированная, {base_duration} дн."
+
+ if device_options:
+ devices_parts = []
+ for d in device_options:
+ if d == 0:
+ devices_parts.append("безлимит")
+ else:
+ devices_parts.append(str(d))
+ devices_str = ", ".join(devices_parts)
+ devices_line = f"📱 Устройства: варианты — {devices_str}"
+ else:
+ devices_line = f"📱 Устройства: выбор отключён, по умолчанию {base_devices}"
+
+ if traffic_options_gb is None:
+ traffic_line = "📦 Трафик: выбор трафика отключён"
+ else:
+ traffic_parts = []
+ for g in traffic_options_gb:
+ if g == 0:
+ traffic_parts.append("безлимит")
+ else:
+ traffic_parts.append(f"{g} ГБ")
+ traffic_str = ", ".join(traffic_parts)
+ traffic_line = f"📦 Трафик: варианты — {traffic_str}"
+
+ device_step = getattr(tariff, "device_step_rub", None) or 0
+ traffic_step = getattr(tariff, "traffic_step_rub", None) or 0
+
+ device_overrides = getattr(tariff, "device_overrides", None) or {}
+ traffic_overrides = getattr(tariff, "traffic_overrides", None) or {}
+
+ base_block = (
+ "
" + "🎯 База тарифа:\n" + f"• Длительность: {base_duration} дней\n" + f"• Устройства: {base_devices}\n" + f"• Трафик: {base_traffic_text}\n" + f"• Цена: {base_price}₽\n" + "\n" + ) + + config_block = f"
\n{duration_line}\n{devices_line}\n{traffic_line}\n\n" + + device_step_line = ( + f"💰 Устройства, базовый шаг: {device_step}₽ за каждое устройство сверх базового лимита ({base_devices})" + ) + + if device_overrides: + parts = [] + for k, v in sorted(device_overrides.items(), key=lambda x: int(x[0])): + devices_count = int(k) + extra = int(v) + if devices_count == 0: + label = "безлимит устройств" + else: + label = f"{devices_count} устройств" + parts.append(f"при {label}: индивидуальная доплата {extra}₽") + device_over_line = "📊 Устройства, индивидуальные доплаты:\n" + "\n".join(f"• {p}" for p in parts) + else: + device_over_line = "📊 Устройства, индивидуальные доплаты: не заданы" + + device_block = f"
{device_step_line}\n{device_over_line}\n\n" + + traffic_step_line = f"💰 Трафик, базовый шаг: {traffic_step}₽ за 1 ГБ сверх базового лимита ({base_traffic_text})" + + if traffic_overrides: + parts = [] + for k, v in sorted(traffic_overrides.items(), key=lambda x: int(x[0])): + gb_value = int(k) + extra = int(v) + if gb_value == 0: + label = "безлимитный трафик" + else: + label = f"лимит {gb_value} ГБ" + parts.append(f"при {label}: индивидуальная доплата {extra}₽") + traffic_over_line = "📊 Трафик, индивидуальные доплаты:\n" + "\n".join(f"• {p}" for p in parts) + else: + traffic_over_line = "📊 Трафик, индивидуальные доплаты: не заданы" + + traffic_block = f"
\n{traffic_step_line}\n{traffic_over_line}\n" + + return ( + f"⚙️ Конфигуратор тарифа: {tariff.name}\n\n" + f"Статус: {configurable_text}\n\n" + f"{base_block}" + f"{config_block}" + f"{device_block}" + f"{traffic_block}" + ) + + +@router.callback_query(F.data.startswith("edit_config|"), IsAdminFilter()) +async def open_config_menu(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + tariff_id = int(callback.data.split("|")[1]) + + result = await session.execute(select(Tariff).where(Tariff.id == tariff_id)) + tariff = result.scalar_one_or_none() + if not tariff: + await callback.message.edit_text("❌ Тариф не найден.") + return + + await state.set_state(TariffConfigState.choosing_section) + await state.update_data(tariff_id=tariff_id) + + text = build_config_summary_text(tariff) + await callback.message.edit_text(text=text, reply_markup=build_config_menu_kb(tariff_id)) diff --git a/handlers/admin/tariffs/tariff_configurator/device_pricing.py b/handlers/admin/tariffs/tariff_configurator/device_pricing.py new file mode 100644 index 00000000..8d7c8500 --- /dev/null +++ b/handlers/admin/tariffs/tariff_configurator/device_pricing.py @@ -0,0 +1,202 @@ +from datetime import datetime + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models import Tariff +from filters.admin import IsAdminFilter + +from .. import router +from .common import ( + TariffConfigState, + build_cancel_config_kb, + build_config_menu_kb, + build_config_summary_text, + build_device_overrides_screen, + calculate_device_formula_extra, +) + + +@router.callback_query(F.data.startswith("cfg_edit_device_step|"), TariffConfigState.choosing_section, IsAdminFilter()) +async def ask_device_step(callback: CallbackQuery, state: FSMContext): + tariff_id = int(callback.data.split("|")[1]) + await state.set_state(TariffConfigState.entering_device_step) + await state.update_data(tariff_id=tariff_id) + + text = ( + "💰 Базовый шаг доплаты за устройства.\n\n" + "Введите цену в рублях за КАЖДОЕ устройство сверх базового лимита.\n" + "Например:
50\n\n"
+ "Чтобы выключить автоматическую доплату за устройства, отправьте 0."
+ )
+ await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
+
+
+@router.message(TariffConfigState.entering_device_step, IsAdminFilter())
+async def save_device_step(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff_id = data["tariff_id"]
+
+ try:
+ price = int(message.text.strip())
+ if price < 0:
+ raise ValueError
+ except ValueError:
+ await message.answer(
+ "❌ Некорректное значение. Введите целое число 0 или больше.",
+ reply_markup=build_cancel_config_kb(tariff_id),
+ )
+ return
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await message.answer("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ tariff.device_step_rub = price
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ await state.set_state(TariffConfigState.choosing_section)
+
+ text = build_config_summary_text(tariff)
+ await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
+
+
+@router.callback_query(F.data.startswith("cfg_edit_device_over|"), TariffConfigState.choosing_section, IsAdminFilter())
+async def open_device_overrides_menu(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ tariff_id = int(callback.data.split("|")[1])
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ return
+
+ device_options = tariff.device_options or []
+ if not device_options:
+ await callback.message.edit_text(
+ "Сначала настройте варианты устройств (кнопка «📱 Варианты устройств»).",
+ reply_markup=build_config_menu_kb(tariff_id),
+ )
+ return
+
+ await state.set_state(TariffConfigState.entering_device_overrides)
+ await state.update_data(tariff_id=tariff_id, devices_override=None)
+
+ text, markup = build_device_overrides_screen(tariff)
+ await callback.message.edit_text(text=text, reply_markup=markup)
+
+
+@router.callback_query(
+ F.data.startswith("cfg_dev_over_item|"), TariffConfigState.entering_device_overrides, IsAdminFilter()
+)
+async def choose_device_override_option(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ parts = callback.data.split("|")
+ tariff_id = int(parts[1])
+ devices = int(parts[2])
+
+ await state.update_data(tariff_id=tariff_id, devices_override=devices)
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ overrides = getattr(tariff, "device_overrides", None) or {}
+ key = str(devices)
+ formula_extra = calculate_device_formula_extra(tariff, devices)
+ override_extra = overrides.get(key)
+ if override_extra is not None:
+ effective_extra = int(override_extra)
+ note = "индивидуальная доплата"
+ else:
+ effective_extra = formula_extra
+ note = "доплата по базовому шагу"
+
+ if devices == 0:
+ label = "безлимитное количество устройств"
+ else:
+ label = f"{devices} устройств"
+
+ text = (
+ f"📊 {label}.\n\n"
+ f"Текущая доплата для этого варианта: {effective_extra}₽ ({note}).\n\n"
+ "Введите новую доплату за устройства для этого варианта в рублях.\n"
+ "Отправьте 0, чтобы вернуть расчёт по базовому шагу."
+ )
+ await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
+
+
+@router.callback_query(
+ F.data.startswith("cfg_dev_over_clear|"), TariffConfigState.entering_device_overrides, IsAdminFilter()
+)
+async def clear_device_overrides(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ tariff_id = int(callback.data.split("|")[1])
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ tariff.device_overrides = None
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ text, markup = build_device_overrides_screen(tariff)
+ await callback.message.edit_text(text=text, reply_markup=markup)
+
+
+@router.message(TariffConfigState.entering_device_overrides, IsAdminFilter())
+async def save_device_override_price(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff_id = data.get("tariff_id")
+ devices = data.get("devices_override")
+
+ if not tariff_id or devices is None:
+ await message.answer("Сначала выберите вариант устройств из списка.")
+ return
+
+ try:
+ extra_price = int(message.text.strip())
+ if extra_price < 0:
+ raise ValueError
+ except ValueError:
+ await message.answer(
+ "❌ Некорректное значение. Введите целое число 0 или больше.",
+ reply_markup=build_cancel_config_kb(int(tariff_id)),
+ )
+ return
+
+ result = await session.execute(select(Tariff).where(Tariff.id == int(tariff_id)))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await message.answer("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ overrides = getattr(tariff, "device_overrides", None) or {}
+ key = str(int(devices))
+
+ if extra_price == 0:
+ overrides.pop(key, None)
+ else:
+ overrides[key] = extra_price
+
+ tariff.device_overrides = overrides or None
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ await state.update_data(devices_override=None)
+
+ text, markup = build_device_overrides_screen(tariff)
+ await message.answer(text=text, reply_markup=markup)
diff --git a/handlers/admin/tariffs/tariff_configurator/options.py b/handlers/admin/tariffs/tariff_configurator/options.py
new file mode 100644
index 00000000..863837d2
--- /dev/null
+++ b/handlers/admin/tariffs/tariff_configurator/options.py
@@ -0,0 +1,144 @@
+import re
+
+from datetime import datetime
+
+from aiogram import F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database.models import Tariff
+from filters.admin import IsAdminFilter
+
+from .. import router
+from .common import (
+ TariffConfigState,
+ build_cancel_config_kb,
+ build_config_menu_kb,
+ build_config_summary_text,
+)
+
+
+@router.callback_query(F.data.startswith("cfg_edit_devices|"), TariffConfigState.choosing_section, IsAdminFilter())
+async def ask_devices_config(callback: CallbackQuery, state: FSMContext):
+ tariff_id = int(callback.data.split("|")[1])
+ await state.set_state(TariffConfigState.entering_devices)
+ await state.update_data(tariff_id=tariff_id)
+
+ text = (
+ "📱 Настройка вариантов устройств.\n\n"
+ "Введите список вариантов количества устройств через пробел или запятую.\n"
+ "Например: 1 3 5\n\n"
+ "Число 0 можно использовать как вариант безлимита.\n"
+ "Чтобы совсем отключить выбор устройств и использовать только базовый лимит тарифа, отправьте единичный 0."
+ )
+ await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
+
+
+@router.message(TariffConfigState.entering_devices, IsAdminFilter())
+async def save_devices_config(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff_id = data["tariff_id"]
+ raw_text = message.text.strip()
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await message.answer("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ if raw_text == "0":
+ tariff.device_options = None
+ else:
+ try:
+ parts = [p for p in re.split(r"[,\s]+", raw_text) if p.strip()]
+ if not parts:
+ raise ValueError
+ values: list[int] = []
+ for part in parts:
+ v = int(part)
+ if v < 0:
+ raise ValueError
+ values.append(v)
+ values = sorted(set(values))
+ tariff.device_options = values
+ except Exception:
+ await message.answer(
+ "❌ Некорректные значения. Введите положительные числа или 0 через пробел или запятую,\n"
+ "например: 1 3 5, 0 1 3 5 (0 как безлимит)\n"
+ "или 0 для отключения выбора устройств.",
+ reply_markup=build_cancel_config_kb(tariff_id),
+ )
+ return
+
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ await state.set_state(TariffConfigState.choosing_section)
+
+ text = build_config_summary_text(tariff)
+ await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
+
+
+@router.callback_query(F.data.startswith("cfg_edit_traffic|"), TariffConfigState.choosing_section, IsAdminFilter())
+async def ask_traffic_config(callback: CallbackQuery, state: FSMContext):
+ tariff_id = int(callback.data.split("|")[1])
+ await state.set_state(TariffConfigState.entering_traffic)
+ await state.update_data(tariff_id=tariff_id)
+
+ text = (
+ "📦 Настройка вариантов трафика.\n\n"
+ "Введите список лимитов трафика в ГБ через пробел или запятую.\n"
+ "Например: 100 200 500\n\n"
+ "Число 0 можно использовать как вариант безлимита.\n"
+ "Чтобы совсем отключить выбор трафика и использовать только базовый лимит тарифа, отправьте единичный 0."
+ )
+ await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
+
+
+@router.message(TariffConfigState.entering_traffic, IsAdminFilter())
+async def save_traffic_config(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff_id = data["tariff_id"]
+ raw_text = message.text.strip()
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await message.answer("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ if raw_text == "0":
+ tariff.traffic_options_gb = None
+ else:
+ try:
+ parts = [p for p in re.split(r"[,\s]+", raw_text) if p.strip()]
+ if not parts:
+ raise ValueError
+ values: list[int] = []
+ for part in parts:
+ v = int(part)
+ if v < 0:
+ raise ValueError
+ values.append(v)
+ values = sorted(set(values))
+ tariff.traffic_options_gb = values
+ except Exception:
+ await message.answer(
+ "❌ Некорректные значения. Введите числа 0 и больше через пробел или запятую,\n"
+ "например: 100 200 500.\n"
+ "0 можно использовать как вариант безлимита или отправить единственный 0 для отключения выбора.",
+ reply_markup=build_cancel_config_kb(tariff_id),
+ )
+ return
+
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ await state.set_state(TariffConfigState.choosing_section)
+
+ text = build_config_summary_text(tariff)
+ await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
diff --git a/handlers/admin/tariffs/tariff_configurator/traffic_pricing.py b/handlers/admin/tariffs/tariff_configurator/traffic_pricing.py
new file mode 100644
index 00000000..7cedf87d
--- /dev/null
+++ b/handlers/admin/tariffs/tariff_configurator/traffic_pricing.py
@@ -0,0 +1,194 @@
+from datetime import datetime
+
+from aiogram import F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from database.models import Tariff
+from filters.admin import IsAdminFilter
+
+from .. import router
+from .common import (
+ TariffConfigState,
+ build_cancel_config_kb,
+ build_config_menu_kb,
+ build_config_summary_text,
+ build_traffic_overrides_screen,
+ calculate_traffic_formula_extra,
+)
+
+
+@router.callback_query(F.data.startswith("cfg_edit_traffic_step|"), TariffConfigState.choosing_section, IsAdminFilter())
+async def ask_traffic_step(callback: CallbackQuery, state: FSMContext):
+ tariff_id = int(callback.data.split("|")[1])
+ await state.set_state(TariffConfigState.entering_traffic_step)
+ await state.update_data(tariff_id=tariff_id)
+
+ text = (
+ "💰 Базовый шаг доплаты за трафик.\n\n"
+ "Введите цену в рублях за 1 ГБ сверх базового лимита.\n"
+ "Например: 5\n\n"
+ "Чтобы выключить автоматическую доплату за трафик, отправьте 0."
+ )
+ await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
+
+
+@router.message(TariffConfigState.entering_traffic_step, IsAdminFilter())
+async def save_traffic_step(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff_id = data["tariff_id"]
+
+ try:
+ price = int(message.text.strip())
+ if price < 0:
+ raise ValueError
+ except ValueError:
+ await message.answer(
+ "❌ Некорректное значение. Введите целое число 0 или больше.",
+ reply_markup=build_cancel_config_kb(tariff_id),
+ )
+ return
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await message.answer("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ tariff.traffic_step_rub = price
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ await state.set_state(TariffConfigState.choosing_section)
+
+ text = build_config_summary_text(tariff)
+ await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
+
+
+@router.callback_query(F.data.startswith("cfg_edit_traffic_over|"), TariffConfigState.choosing_section, IsAdminFilter())
+async def open_traffic_overrides_menu(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ tariff_id = int(callback.data.split("|")[1])
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ return
+
+ await state.set_state(TariffConfigState.entering_traffic_overrides)
+ await state.update_data(tariff_id=tariff_id, traffic_override_gb=None)
+
+ text, markup = build_traffic_overrides_screen(tariff)
+ await callback.message.edit_text(text=text, reply_markup=markup)
+
+
+@router.callback_query(
+ F.data.startswith("cfg_trf_over_item|"), TariffConfigState.entering_traffic_overrides, IsAdminFilter()
+)
+async def choose_traffic_override_option(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ parts = callback.data.split("|")
+ tariff_id = int(parts[1])
+ gb_value = int(parts[2])
+
+ await state.update_data(tariff_id=tariff_id, traffic_override_gb=gb_value)
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ overrides = getattr(tariff, "traffic_overrides", None) or {}
+ key = str(gb_value)
+ formula_extra = calculate_traffic_formula_extra(tariff, gb_value)
+ override_extra = overrides.get(key)
+ if override_extra is not None:
+ effective_extra = int(override_extra)
+ note = "индивидуальная доплата"
+ else:
+ effective_extra = formula_extra
+ note = "доплата по базовому шагу"
+
+ if gb_value == 0:
+ label = "безлимитный трафик"
+ else:
+ label = f"лимит {gb_value} ГБ"
+
+ text = (
+ f"📊 {label}.\n\n"
+ f"Текущая доплата для этого варианта: {effective_extra}₽ ({note}).\n\n"
+ "Введите новую доплату за трафик для этого лимита в рублях.\n"
+ "Отправьте 0, чтобы вернуть расчёт по базовому шагу."
+ )
+ await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
+
+
+@router.callback_query(
+ F.data.startswith("cfg_trf_over_clear|"), TariffConfigState.entering_traffic_overrides, IsAdminFilter()
+)
+async def clear_traffic_overrides(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ tariff_id = int(callback.data.split("|")[1])
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ tariff.traffic_overrides = None
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ text, markup = build_traffic_overrides_screen(tariff)
+ await callback.message.edit_text(text=text, reply_markup=markup)
+
+
+@router.message(TariffConfigState.entering_traffic_overrides, IsAdminFilter())
+async def save_traffic_override_price(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff_id = data.get("tariff_id")
+ gb_value = data.get("traffic_override_gb")
+
+ if not tariff_id or gb_value is None:
+ await message.answer("Сначала выберите вариант лимита трафика из списка.")
+ return
+
+ try:
+ extra_price = int(message.text.strip())
+ if extra_price < 0:
+ raise ValueError
+ except ValueError:
+ await message.answer(
+ "❌ Некорректное значение. Введите целое число 0 или больше.",
+ reply_markup=build_cancel_config_kb(int(tariff_id)),
+ )
+ return
+
+ result = await session.execute(select(Tariff).where(Tariff.id == int(tariff_id)))
+ tariff = result.scalar_one_or_none()
+ if not tariff:
+ await message.answer("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ overrides = getattr(tariff, "traffic_overrides", None) or {}
+ key = str(int(gb_value))
+
+ if extra_price == 0:
+ overrides.pop(key, None)
+ else:
+ overrides[key] = extra_price
+
+ tariff.traffic_overrides = overrides or None
+ tariff.updated_at = datetime.utcnow()
+ await session.commit()
+
+ await state.update_data(traffic_override_gb=None)
+
+ text, markup = build_traffic_overrides_screen(tariff)
+ await message.answer(text=text, reply_markup=markup)
diff --git a/handlers/admin/tariffs/tariff_manage.py b/handlers/admin/tariffs/tariff_manage.py
index ea1f5327..86db2fb2 100644
--- a/handlers/admin/tariffs/tariff_manage.py
+++ b/handlers/admin/tariffs/tariff_manage.py
@@ -587,3 +587,24 @@ async def start_tariff_creation_existing_group(
f"📦 Добавление нового тарифа в группу {group_code}\n\n📝 Введите название тарифа:",
reply_markup=build_cancel_kb(),
)
+
+
+@router.callback_query(F.data.startswith("toggle_configurable|"), IsAdminFilter())
+async def toggle_tariff_configurable(callback: CallbackQuery, session: AsyncSession):
+ tariff_id = int(callback.data.split("|")[1])
+
+ result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
+ tariff = result.scalar_one_or_none()
+
+ if not tariff:
+ await callback.message.edit_text("❌ Тариф не найден.")
+ return
+
+ current = bool(tariff.configurable)
+ tariff.configurable = not current
+ tariff.updated_at = datetime.utcnow()
+
+ await session.commit()
+
+ text, markup = render_tariff_card(tariff)
+ await callback.message.edit_text(text=text, reply_markup=markup)
diff --git a/handlers/admin/tariffs/tariff_utils.py b/handlers/admin/tariffs/tariff_utils.py
index e4cf6af1..65d1a081 100644
--- a/handlers/admin/tariffs/tariff_utils.py
+++ b/handlers/admin/tariffs/tariff_utils.py
@@ -39,6 +39,8 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
device_text = f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
sort_order = getattr(tariff, "sort_order", 1)
vless_text = "Да" if getattr(tariff, "vless", False) else "Нет"
+ configurable = bool(getattr(tariff, "configurable", False))
+ configurable_text = "Включен" if configurable else "Выключен"
text = (
f"📄 Тариф: {tariff.name}\n\n"
@@ -48,8 +50,9 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
f"📦 Трафик: {traffic_text}\n"
f"📱 Устройств: {device_text}\n"
f"🔗 VLESS: {vless_text}\n"
+ f"⚙️ Конфигуратор: {configurable_text}\n"
f"🔢 Позиция: {sort_order}\n"
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
)
- return text, build_single_tariff_kb(tariff.id, tariff.group_code)
+ return text, build_single_tariff_kb(tariff.id, tariff.group_code, configurable=configurable)
diff --git a/handlers/admin/users/__init__.py b/handlers/admin/users/__init__.py
index c75b06de..52c16106 100644
--- a/handlers/admin/users/__init__.py
+++ b/handlers/admin/users/__init__.py
@@ -1,6 +1,6 @@
from aiogram import Router
-from . import users_bans, users_hwid, users_keys, users_manage, users_balance
+from . import users_balance, users_bans, users_hwid, users_keys, users_manage
router = Router()
diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py
index 85ed9f36..3bbeb754 100644
--- a/handlers/admin/users/keyboard.py
+++ b/handlers/admin/users/keyboard.py
@@ -246,7 +246,9 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
- is_frozen = key_details.get("is_frozen") if isinstance(key_details, dict) else getattr(key_details, "is_frozen", False)
+ is_frozen = (
+ key_details.get("is_frozen") if isinstance(key_details, dict) else getattr(key_details, "is_frozen", False)
+ )
builder.button(
text="⏳ Время истечения",
@@ -287,9 +289,7 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
else:
builder.button(
text=FREEZE,
- callback_data=AdminUserEditorCallback(
- action="users_freeze", data=email, tg_id=key_details["tg_id"]
- ).pack(),
+ callback_data=AdminUserEditorCallback(action="users_freeze", data=email, tg_id=key_details["tg_id"]).pack(),
)
if HWID_RESET_BUTTON:
diff --git a/handlers/admin/users/users_balance.py b/handlers/admin/users/users_balance.py
index 7dd430c9..161f86fa 100644
--- a/handlers/admin/users/users_balance.py
+++ b/handlers/admin/users/users_balance.py
@@ -26,10 +26,7 @@ def format_admin_operation(amount: float, created_at: datetime) -> str:
date_str = created_at.strftime("%Y-%m-%d %H:%M:%S")
sign = "+" if amount > 0 else "-" if amount < 0 else ""
abs_amount = abs(amount)
- return (
- f"\nАдмин {sign}{abs_amount}Р" - f"\n⏳ Дата: {date_str}" - ) + return f"\n
Админ {sign}{abs_amount}Р\n⏳ Дата: {date_str}" def format_user_payment(amount: float, created_at: datetime, payment_system: str, status: str) -> str: @@ -37,9 +34,7 @@ def format_user_payment(amount: float, created_at: datetime, payment_system: str abs_amount = abs(amount) system_name = payment_system or "Неизвестно" return ( - f"\n
💸 Сумма: {abs_amount} | {system_name}" - f"\n📌 Статус: {status}" - f"\n⏳ Дата: {date_str}" + f"\n
💸 Сумма: {abs_amount} | {system_name}\n📌 Статус: {status}\n⏳ Дата: {date_str}" ) @@ -68,21 +63,14 @@ async def handle_balance_change( stmt_user = ( select(Payment.amount, Payment.created_at, Payment.payment_system, Payment.status) - .where( - Payment.tg_id == tg_id, - Payment.payment_system != "admin" - ) + .where(Payment.tg_id == tg_id, Payment.payment_system != "admin") .order_by(Payment.created_at.desc()) .limit(5) ) result_user = await session.execute(stmt_user) user_records = result_user.all() - text = ( - f"💵 Изменение баланса" - f"\n\n🆔 ID: {tg_id}" - f"\n💰 Баланс: {balance}Р" - ) + text = f"💵 Изменение баланса\n\n🆔 ID: {tg_id}\n💰 Баланс: {balance}Р" text += "\n\n📊 Операции админа (5):" if admin_records: diff --git a/handlers/admin/users/users_keys.py b/handlers/admin/users/users_keys.py index adbda4bd..da28223a 100644 --- a/handlers/admin/users/users_keys.py +++ b/handlers/admin/users/users_keys.py @@ -41,7 +41,6 @@ from handlers.utils import generate_random_email, handle_error from hooks.hook_buttons import insert_hook_buttons from hooks.processors import process_admin_key_edit_menu from logger import logger -from aiogram.utils.keyboard import InlineKeyboardBuilder from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb from .keyboard import ( @@ -1026,8 +1025,7 @@ async def handle_admin_freeze_subscription( result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session) if result["status"] != "success": text_error = ( - "Произошла ошибка при заморозке подписки.\n" - f"Детали: {result.get('error') or result.get('results')}" + f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}" ) await callback_query.message.edit_text( text_error, @@ -1082,8 +1080,7 @@ async def handle_admin_unfreeze_subscription( result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True, session=session) if result["status"] != "success": text_error = ( - "Произошла ошибка при включении подписки.\n" - f"Детали: {result.get('error') or result.get('results')}" + f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}" ) await callback_query.message.edit_text( text_error, diff --git a/handlers/buttons.py b/handlers/buttons.py index d7bb5841..2b6f5500 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -21,6 +21,11 @@ TRIAL_SUB = "🎁 Пробная подписка" MY_SUB = "🔐 Моя подписка" RENEW_SUB = "🔄 Обновить подписку" +ADDONS_BUTTON_DEVICES = "➕ Добавить устройства" +ADDONS_BUTTON_TRAFFIC = "➕ Добавить трафик" +ADDONS_BUTTON_DEVICES_TRAFFIC = "➕ Добавить устройства/трафик" + + # Меню Оплат и баланса BALANCE_HISTORY = "📊 История пополнения" @@ -86,6 +91,11 @@ TV_INSTRUCTIONS = "📖 Полная инструкция" HWID_BUTTON = "♻️ Сбросить привязку" ROUTER_BUTTON = "Подключить роутер" +# Кнопки конфигуратора +DOWNGRADE_ADDON_BUTTON_TEXT = "Понизить условия" +DOWNGRADE_CONFIRM_BUTTON_TEXT = "Подтвердить понижение" +CONFIG_PAY_BUTTON_TEXT = "Оплатить {amount}" +CONFIRM_ADDON_BUTTON_TEXT = "Подтвердить доплату {amount}" # Кнопки касс diff --git a/handlers/instructions/instructions.py b/handlers/instructions/instructions.py index cf99d88c..78144f50 100644 --- a/handlers/instructions/instructions.py +++ b/handlers/instructions/instructions.py @@ -7,18 +7,18 @@ from aiogram import F, Router from aiogram.types import CallbackQuery, InlineKeyboardButton, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from core.bootstrap import MODES_CONFIG from config import ( CONNECT_MACOS, CONNECT_WINDOWS, DOWNLOAD_MACOS, DOWNLOAD_PC, + HAPP_CRYPTOLINK, + REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL, WEBHOOK_HOST, - REMNAWAVE_WEBAPP, - HAPP_CRYPTOLINK, ) -from database import get_subscription_link, get_key_details +from core.bootstrap import MODES_CONFIG +from database import get_key_details, get_subscription_link from handlers.buttons import ( BACK, CONNECT_MACOS_BUTTON, diff --git a/handlers/keys/key_mode/key_create.py b/handlers/keys/key_create.py similarity index 58% rename from handlers/keys/key_mode/key_create.py rename to handlers/keys/key_create.py index 018d88cb..165cfc8e 100644 --- a/handlers/keys/key_mode/key_create.py +++ b/handlers/keys/key_create.py @@ -1,6 +1,5 @@ from collections import defaultdict from datetime import datetime, timedelta -from math import ceil from typing import Any import pytz @@ -10,20 +9,18 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardButton, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from config import ( DISCOUNT_ACTIVE_HOURS, NOTIFY_EXTRA_DAYS, TRIAL_TIME_DISABLE, USE_COUNTRY_SELECTION, - USE_NEW_PAYMENT_FLOW, ) from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG from database import ( add_user, check_user_exists, - get_balance, - get_tariff_by_id, get_tariffs_for_cluster, get_trial, ) @@ -31,32 +28,28 @@ from database.models import Admin from database.notifications import check_hot_lead_discount from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs from handlers.admin.panel.keyboard import AdminPanelCallback -from handlers.buttons import MAIN_MENU, PAYMENT +from handlers.buttons import MAIN_MENU from handlers.payments.currency_rates import format_for_user -from handlers.payments.fast_payment_flow import try_fast_payment_flow from handlers.texts import ( CREATING_CONNECTION_MSG, DISCOUNT_OFFER_MESSAGE, DISCOUNT_OFFER_STEP2, DISCOUNT_OFFER_STEP3, - INSUFFICIENT_FUNDS_MSG, SELECT_TARIFF_PLAN_MSG, ) from handlers.utils import edit_or_send_message, format_discount_time_left, get_least_loaded_cluster from hooks.hook_buttons import insert_hook_buttons from hooks.processors import ( - process_check_discount_validity, process_purchase_tariff_group_override, process_tariff_menu, ) from logger import logger -from .key_cluster_mode import key_cluster_mode -from .key_country_mode import key_country_mode +from .key_mode.key_cluster_mode import key_cluster_mode +from .key_mode.key_country_mode import key_country_mode router = Router() - moscow_tz = pytz.timezone("Europe/Moscow") @@ -67,12 +60,16 @@ class Form(FSMContext): @router.callback_query(F.data == "create_key") @router.callback_query(F.data == "buy") @router.message(F.text == "/buy") -async def confirm_create_new_key(callback_query_or_message: CallbackQuery | Message, state: FSMContext, session: Any): +async def confirm_create_new_key( + callback_query_or_message: CallbackQuery | Message, + state: FSMContext, + session: AsyncSession, +): if isinstance(callback_query_or_message, CallbackQuery): - tg_id = callback_query_or_message.message.chat.id - message_or_query = callback_query_or_message + tg_id = callback_query_or_message.from_user.id + message_or_query: Message | CallbackQuery = callback_query_or_message else: - tg_id = callback_query_or_message.chat.id + tg_id = callback_query_or_message.from_user.id message_or_query = callback_query_or_message await handle_key_creation(tg_id, state, session, message_or_query) @@ -81,7 +78,7 @@ async def confirm_create_new_key(callback_query_or_message: CallbackQuery | Mess async def handle_key_creation( tg_id: int, state: FSMContext, - session: Any, + session: AsyncSession, message_or_query: Message | CallbackQuery, ): state_data = await state.get_data() @@ -91,6 +88,9 @@ async def handle_key_creation( await state.update_data(key_creation_in_progress=True) + target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query + language_code = message_or_query.from_user.language_code + try: current_time = datetime.now(moscow_tz) @@ -100,16 +100,12 @@ async def handle_key_creation( if trial_status in [0, -1]: trial_tariffs = await get_tariffs(session, group_code="trial") if not trial_tariffs: + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( - target_message=( - message_or_query.message - if isinstance(message_or_query, CallbackQuery) - else message_or_query - ), + target_message=target_message, text="❌ Пробная подписка временно недоступна.", - reply_markup=InlineKeyboardBuilder() - .row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - .as_markup(), + reply_markup=builder.as_markup(), ) return @@ -123,42 +119,40 @@ async def handle_key_creation( logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}") await edit_or_send_message( - target_message=( - message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query - ), + target_message=target_message, text=CREATING_CONNECTION_MSG, reply_markup=None, ) await state.update_data(is_trial=True, plan=trial_tariff["id"]) - await create_key(tg_id, expiry_time, state, session, message_or_query, plan=trial_tariff["id"]) + await create_key( + tg_id=tg_id, + expiry_time=expiry_time, + state=state, + session=session, + message_or_query=message_or_query, + old_key_name=None, + plan=trial_tariff["id"], + ) return try: cluster_name = await get_least_loaded_cluster(session) except ValueError as e: logger.error(f"Нет доступных кластеров: {e}") + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( - target_message=( - message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query - ), + target_message=target_message, text=str(e), - reply_markup=InlineKeyboardBuilder() - .row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - .as_markup(), + reply_markup=builder.as_markup(), ) return tariffs = await get_tariffs_for_cluster(session, cluster_name) - language_code = ( - message_or_query.from_user.language_code - if not isinstance(message_or_query, CallbackQuery) - else message_or_query.from_user.language_code - ) - - discount_info = None - subgroup_weights = {} + discount_info: dict[str, Any] | None = None + subgroup_weights: dict[str, int] = {} if tariffs: group_code = tariffs[0].get("group_code") @@ -183,7 +177,11 @@ async def handle_key_creation( if override_result.get("discount_info"): await state.update_data(discount_info=override_result["discount_info"]) - tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True) + tariffs_data = await get_tariffs( + session, + group_code=group_code, + with_subgroup_weights=True, + ) tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")] subgroup_weights = tariffs_data["subgroup_weights"] @@ -195,17 +193,18 @@ async def handle_key_creation( builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( - text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack() + text="🔗 Привязать тариф", + callback_data=AdminPanelCallback(action="clusters").pack(), ) ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) text = ( - f"🚫 Невозможно создать подписку\n\n" - f"📊 Информация о кластере:\n
" + "🚫 Невозможно создать подписку\n\n" + "📊 Информация о кластере:\n" + "💡 Привяжите тариф к кластеру" ) else: builder = InlineKeyboardBuilder() @@ -213,9 +212,7 @@ async def handle_key_creation( text = "❌ Нет доступных тарифов для выбора." await edit_or_send_message( - target_message=( - message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query - ), + target_message=target_message, text=text, reply_markup=builder.as_markup(), ) @@ -224,55 +221,53 @@ async def handle_key_creation( group_code = tariffs[0].get("group_code") if tariffs else None if not group_code: await edit_or_send_message( - target_message=( - message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query - ), + target_message=target_message, text="❌ Не удалось определить группу тарифов.", reply_markup=None, ) return - grouped_tariffs = defaultdict(list) - for t in tariffs: - subgroup = t.get("subgroup_title") - grouped_tariffs[subgroup].append(t) + grouped_tariffs: dict[str | None, list[dict[str, Any]]] = defaultdict(list) + for tariff in tariffs: + subgroup = tariff.get("subgroup_title") + grouped_tariffs[subgroup].append(tariff) builder = InlineKeyboardBuilder() - for t in grouped_tariffs.get(None, []): - price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code) + for tariff in grouped_tariffs.get(None, []): + price_text = await format_for_user(session, tg_id, tariff.get("price_rub", 0), language_code) builder.row( InlineKeyboardButton( - text=f"{t['name']} — {price_txt}", - callback_data=f"select_tariff_plan|{t['id']}", + text=f"{tariff['name']} — {price_text}", + callback_data=f"select_tariff_plan|{tariff['id']}", ) ) sorted_subgroups = sorted( - [k for k in grouped_tariffs if k], - key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x), + [key for key in grouped_tariffs if key], + key=lambda title: (subgroup_weights.get(title, 999999) if subgroup_weights else 999999, title), ) for subgroup in sorted_subgroups: subgroup_hash = create_subgroup_hash(subgroup, group_code) builder.row( InlineKeyboardButton( - text=f"{subgroup}", + text=subgroup, callback_data=f"tariff_subgroup_user|{subgroup_hash}", ) ) tariff_menu_buttons = await process_tariff_menu( - group_code=group_code, cluster_name=cluster_name, tg_id=tg_id, session=session + group_code=group_code, + cluster_name=cluster_name, + tg_id=tg_id, + session=session, ) builder = insert_hook_buttons(builder, tariff_menu_buttons) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query - discount_message = "" - if discount_info and discount_info.get("available"): offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3 expires_at = discount_info["expires_at"] @@ -289,7 +284,11 @@ async def handle_key_creation( reply_markup=builder.as_markup(), ) - await state.update_data(tg_id=tg_id, cluster_name=cluster_name, group_code=group_code) + await state.update_data( + tg_id=tg_id, + cluster_name=cluster_name, + group_code=group_code, + ) await state.set_state(Form.waiting_for_server_selection) finally: @@ -297,7 +296,7 @@ async def handle_key_creation( @router.callback_query(F.data.startswith("tariff_subgroup_user|")) -async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: Any): +async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: AsyncSession): subgroup_hash = callback.data.split("|")[1] data = await state.get_data() cluster_name = data.get("cluster_name") @@ -312,14 +311,16 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte ) return - tariffs = await get_tariffs_for_cluster(session, cluster_name) - filtered = [] + tariffs_for_cluster = await get_tariffs_for_cluster(session, cluster_name) + filtered: list[dict[str, Any]] = [] - if tariffs: - group_code = tariffs[0].get("group_code") + if tariffs_for_cluster: + group_code = tariffs_for_cluster[0].get("group_code") if group_code: tariffs = await get_tariffs(session, group_code=group_code) - filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")] + filtered = [ + tariff for tariff in tariffs if tariff.get("subgroup_title") == subgroup and tariff.get("is_active") + ] if not filtered: await edit_or_send_message( @@ -333,12 +334,12 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte language_code = callback.from_user.language_code builder = InlineKeyboardBuilder() - for t in filtered: - price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code) + for tariff in filtered: + price_text = await format_for_user(session, tg_id, tariff.get("price_rub", 0), language_code) builder.row( InlineKeyboardButton( - text=f"{t['name']} — {price_txt}", - callback_data=f"select_tariff_plan|{t['id']}", + text=f"{tariff['name']} — {price_text}", + callback_data=f"select_tariff_plan|{tariff['id']}", ) ) builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_tariff_group_list")) @@ -352,8 +353,7 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte @router.callback_query(F.data == "back_to_tariff_group_list") -async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any): - await state.get_data() +async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: AsyncSession): tg_id = callback.from_user.id await handle_key_creation( tg_id=tg_id, @@ -363,110 +363,18 @@ async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, ) -@router.callback_query(F.data.startswith("select_tariff_plan|")) -async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext): - tg_id = callback_query.from_user.id - tariff_id = int(callback_query.data.split("|")[1]) - - tariff = await get_tariff_by_id(session, tariff_id) - if not tariff: - await edit_or_send_message( - target_message=callback_query.message, - text="❌ Указанный тариф не найден.", - ) - await callback_query.answer() - return - - discount_info = await check_hot_lead_discount(session, tg_id) - if tariff.get("group_code") in ["discounts", "discounts_max"]: - if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - await edit_or_send_message( - target_message=callback_query.message, - text="❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.", - reply_markup=builder.as_markup(), - ) - await callback_query.answer() - return - - validity_result = await process_check_discount_validity( - chat_id=tg_id, - admin=False, - session=session, - tariff_group=tariff.get("group_code"), - ) - if validity_result: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - await edit_or_send_message( - target_message=callback_query.message, - text=validity_result["message"], - reply_markup=builder.as_markup(), - ) - await callback_query.answer() - return - - duration_days = tariff["duration_days"] - price_rub = tariff["price_rub"] - - balance = await get_balance(session, tg_id) - - if balance < price_rub: - required_amount = ceil(price_rub - balance) - - if USE_NEW_PAYMENT_FLOW: - handled = await try_fast_payment_flow( - callback_query, - session, - state, - tg_id=tg_id, - temp_key="waiting_for_payment", - temp_payload={ - "tariff_id": tariff_id, - "duration_days": duration_days, - "required_amount": required_amount, - }, - required_amount=required_amount, - ) - if handled: - return - - language_code = getattr(callback_query.from_user, "language_code", None) - required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) - builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - await edit_or_send_message( - target_message=callback_query.message, - text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount_text), - reply_markup=builder.as_markup(), - ) - return - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key")) - await edit_or_send_message( - target_message=callback_query.message, - text=CREATING_CONNECTION_MSG, - reply_markup=builder.as_markup(), - ) - await callback_query.answer() - - expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days) - await state.update_data(tariff_id=tariff_id) - await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id) - - async def create_key( tg_id: int, - expiry_time, - state, - session, - message_or_query=None, - old_key_name: str = None, - plan: int = None, + expiry_time: datetime, + state: FSMContext | None, + session: AsyncSession, + message_or_query: Message | CallbackQuery | None = None, + old_key_name: str | None = None, + plan: int | None = None, + selected_duration_days: int | None = None, + selected_device_limit: int | None = None, + selected_traffic_gb: int | None = None, + selected_price_rub: int | None = None, ): if not await check_user_exists(session, tg_id): from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None @@ -483,6 +391,21 @@ async def create_key( use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)) + if state and any( + value is not None + for value in (selected_duration_days, selected_device_limit, selected_traffic_gb, selected_price_rub) + ): + state_data = await state.get_data() + if selected_duration_days is not None: + state_data["config_selected_duration_days"] = selected_duration_days + if selected_device_limit is not None: + state_data["config_selected_device_limit"] = selected_device_limit + if selected_traffic_gb is not None: + state_data["config_selected_traffic_gb"] = selected_traffic_gb + if selected_price_rub is not None: + state_data["config_selected_price_rub"] = selected_price_rub + await state.set_data(state_data) + if use_country_selection: await key_country_mode( tg_id=tg_id, @@ -492,6 +415,9 @@ async def create_key( message_or_query=message_or_query, old_key_name=old_key_name, plan=plan, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + selected_price_rub=selected_price_rub, ) else: await key_cluster_mode( @@ -501,4 +427,7 @@ async def create_key( session=session, message_or_query=message_or_query, plan=plan, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + selected_price_rub=selected_price_rub, ) diff --git a/handlers/keys/key_mode/__init__.py b/handlers/keys/key_mode/__init__.py index 5fbf209f..06ad2d2b 100644 --- a/handlers/keys/key_mode/__init__.py +++ b/handlers/keys/key_mode/__init__.py @@ -2,12 +2,19 @@ __all__ = ("router",) from aiogram import Router +from ...tariffs.buy.key_tariffs import router as tariffs_router +from ..key_create import router as create_router from .key_cluster_mode import router as cluster_router from .key_country_mode import router as country_router -from .key_create import router as create_router from .key_discount_mode import router as discount_router router = Router(name="key_mode_router") -router.include_routers(create_router, cluster_router, country_router, discount_router) +router.include_routers( + create_router, + tariffs_router, + cluster_router, + country_router, + discount_router, +) diff --git a/handlers/keys/key_mode/key_cluster_mode.py b/handlers/keys/key_mode/key_cluster_mode.py index 36fcda5f..f3a2e8b1 100644 --- a/handlers/keys/key_mode/key_cluster_mode.py +++ b/handlers/keys/key_mode/key_cluster_mode.py @@ -12,17 +12,18 @@ from aiogram.types import ( WebAppInfo, ) from aiogram.utils.keyboard import InlineKeyboardBuilder +from sqlalchemy import update from bot import bot from config import REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL -from core.bootstrap import MODES_CONFIG +from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG from database import ( get_key_details, - get_tariff_by_id, get_trial, update_balance, update_trial, ) +from database.models import Key from handlers.buttons import ( CONNECT_DEVICE, MAIN_MENU, @@ -32,7 +33,12 @@ from handlers.buttons import ( TV_BUTTON, ) from handlers.keys.operations import create_key_on_cluster -from handlers.texts import key_message_success +from handlers.tariffs.tariff_display import ( + build_key_created_message, + get_effective_limits_for_key, + resolve_price_to_charge, + resolve_vless_enabled, +) from handlers.utils import ( edit_or_send_message, generate_random_email, @@ -59,7 +65,10 @@ async def key_cluster_mode( state, session, message_or_query: Message | CallbackQuery | None = None, - plan: int = None, + plan: int | None = None, + selected_device_limit: int | None = None, + selected_traffic_gb: int | None = None, + selected_price_rub: int | None = None, ): target_message = None safe_to_edit = False @@ -85,19 +94,26 @@ async def key_cluster_mode( data = await state.get_data() if state else {} is_trial = data.get("is_trial", False) - device_limit = 0 - traffic_limit_gb = 0 + if selected_device_limit is None: + selected_device_limit = data.get("config_selected_device_limit") or data.get("selected_device_limit") - if plan: - tariff = await get_tariff_by_id(session, plan) - if tariff: - if tariff.get("device_limit") is not None: - device_limit = int(tariff["device_limit"]) - if tariff.get("traffic_limit") is not None: - traffic_limit_gb = int(tariff["traffic_limit"]) + if selected_traffic_gb is None: + selected_traffic_gb = data.get("config_selected_traffic_gb") or data.get("selected_traffic_limit_gb") + + effective_tariff_id = plan or data.get("tariff_id") + + device_limit, traffic_limit_bytes = await get_effective_limits_for_key( + session=session, + tariff_id=effective_tariff_id, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + ) forced_cluster = await process_cluster_override( - tg_id=tg_id, state_data=data, session=session, plan=plan + tg_id=tg_id, + state_data=data, + session=session, + plan=plan, ) if forced_cluster: @@ -119,6 +135,16 @@ async def key_cluster_mode( await bot.send_message(chat_id=tg_id, text=error_message) return + if device_limit is None: + device_limit = 0 + if traffic_limit_bytes is None: + traffic_limit_bytes = 0 + + if selected_price_rub is not None: + price_to_charge = selected_price_rub + else: + price_to_charge = await resolve_price_to_charge(session, data) + await create_key_on_cluster( cluster_id=least_loaded_cluster, tg_id=tg_id, @@ -128,29 +154,36 @@ async def key_cluster_mode( plan=plan, session=session, hwid_limit=device_limit, - traffic_limit_bytes=traffic_limit_gb, + traffic_limit_bytes=traffic_limit_bytes, is_trial=is_trial, ) logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}") + await session.execute( + update(Key) + .where(Key.tg_id == tg_id, Key.email == email) + .values( + selected_device_limit=selected_device_limit, + selected_traffic_limit=selected_traffic_gb, + selected_price_rub=price_to_charge, + ) + ) + await session.commit() + key_record = await get_key_details(session, email) if not key_record: raise ValueError(f"Ключ не найден после создания: {email}") - public_link = key_record.get("key") - remnawave_link = key_record.get("remnawave_link") - final_link = public_link or remnawave_link or "" + final_link = key_record.get("link", "") if is_trial: trial_status = await get_trial(session, tg_id) if trial_status in [0, -1]: await update_trial(session, tg_id, 1) - if data.get("tariff_id"): - tariff = await get_tariff_by_id(session, data["tariff_id"]) - if tariff: - await update_balance(session, tg_id, -tariff["price_rub"]) + if price_to_charge: + await update_balance(session, tg_id, -int(price_to_charge)) except Exception as e: logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}") @@ -169,14 +202,14 @@ async def key_cluster_mode( vless_enabled = False try: if plan: - ti = await get_tariff_by_id(session, plan) - vless_enabled = bool(ti.get("vless")) if ti else False + vless_enabled = await resolve_vless_enabled(session, plan) elif key_record.get("tariff_id"): - ti = await get_tariff_by_id(session, key_record["tariff_id"]) - vless_enabled = bool(ti.get("vless")) if ti else False + vless_enabled = await resolve_vless_enabled(session, key_record["tariff_id"]) except Exception: vless_enabled = False + tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")) + builder = InlineKeyboardBuilder() if vless_enabled: builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) @@ -197,7 +230,8 @@ async def key_cluster_mode( and final_link.startswith(("http://", "https://")) ): builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")) + if tv_button_enabled: + builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")) else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) else: @@ -208,32 +242,28 @@ async def key_cluster_mode( builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) if await process_intercept_key_creation_message( - chat_id=tg_id, session=session, target_message=message_or_query + chat_id=tg_id, + session=session, + target_message=message_or_query, ): return hook_commands = await process_key_creation_complete( - chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name + chat_id=tg_id, + admin=False, + session=session, + email=email, + key_name=key_name, ) if hook_commands: builder = insert_hook_buttons(builder, hook_commands) - expiry_time_local = expiry_time.astimezone(moscow_tz) - expiry_time_local - datetime.now(moscow_tz) - - tariff_info = None - if plan: - tariff_info = await get_tariff_by_id(session, plan) - - tariff_duration = tariff_info["name"] - subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else "" - - key_message_text = key_message_success( - final_link, - tariff_name=tariff_duration, - traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0, - device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0, - subgroup_title=subgroup_title, + key_message_text = await build_key_created_message( + session=session, + key_record=key_record, + final_link=final_link, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, ) default_media_path = "img/pic.jpg" diff --git a/handlers/keys/key_mode/key_country_mode.py b/handlers/keys/key_mode/key_country_mode.py index f0315f2f..5852d2ed 100644 --- a/handlers/keys/key_mode/key_country_mode.py +++ b/handlers/keys/key_mode/key_country_mode.py @@ -19,13 +19,12 @@ from bot import bot from config import ( ADMIN_PASSWORD, ADMIN_USERNAME, - HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL, ) -from core.bootstrap import MODES_CONFIG +from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG from database import ( add_user, check_server_name_by_cluster, @@ -37,7 +36,7 @@ from database import ( update_balance, update_trial, ) -from database.models import Key, Server, ServerSpecialgroup, Tariff +from database.models import Key, Server, ServerSpecialgroup from handlers.buttons import ( BACK, CONNECT_DEVICE, @@ -49,7 +48,11 @@ from handlers.buttons import ( ) from handlers.keys.operations import create_client_on_server from handlers.keys.operations.aggregated_links import make_aggregated_link -from handlers.texts import SELECT_COUNTRY_MSG, key_message_success +from handlers.tariffs.tariff_display import ( + build_key_created_message, + get_effective_limits_for_key, +) +from handlers.texts import SELECT_COUNTRY_MSG from handlers.utils import ( ALLOWED_GROUP_CODES, edit_or_send_message, @@ -70,8 +73,8 @@ from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_usern router = Router() - moscow_tz = pytz.timezone("Europe/Moscow") +GB = 1024 * 1024 * 1024 async def key_country_mode( @@ -80,8 +83,8 @@ async def key_country_mode( state: FSMContext, session: AsyncSession, message_or_query: Message | CallbackQuery | None = None, - old_key_name: str = None, - plan: int = None, + old_key_name: str | None = None, + plan: int | None = None, ): target_message = None safe_to_edit = False @@ -99,7 +102,10 @@ async def key_country_mode( data = await state.get_data() if state else {} forced_cluster = await process_cluster_override( - tg_id=tg_id, state_data=data, session=session, plan=plan + tg_id=tg_id, + state_data=data, + session=session, + plan=plan, ) if forced_cluster: least_loaded_cluster = forced_cluster @@ -115,9 +121,9 @@ async def key_country_mode( return subgroup_title = None - tariff = None + tariff: dict[str, Any] | None = None if plan: - tariff = await get_tariff_by_id(session, plan) + tariff = await get_tariff_by_id(session, int(plan)) if tariff: subgroup_title = tariff.get("subgroup_title") @@ -140,7 +146,7 @@ async def key_country_mode( return server_ids = [s["id"] for s in servers] - groups_map = {} + groups_map: dict[int, list[str]] = {} if server_ids: r = await session.execute( select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where( @@ -168,7 +174,7 @@ async def key_country_mode( gc = (tariff.get("group_code") or "").lower() if gc in ALLOWED_GROUP_CODES: special = gc - + if special: bound_servers = [s for s in servers if special in (s.get("special_groups") or [])] if bound_servers: @@ -181,7 +187,7 @@ async def key_country_mode( await bot.send_message(chat_id=tg_id, text=text) return - available_servers = [] + available_servers: list[str] = [] tasks = [asyncio.create_task(check_server_availability(dict(server), session)) for server in servers] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -252,13 +258,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): cluster_name = cluster_info["cluster_name"] key_tariff_id = record.get("tariff_id") - tariff_obj = None + tariff_dict: dict[str, Any] | None = None subgroup_title = None if key_tariff_id: - res = await session.execute(select(Tariff).where(Tariff.id == key_tariff_id)) - tariff_obj = res.scalar_one_or_none() - if tariff_obj: - subgroup_title = tariff_obj.subgroup_title + tariff_dict = await get_tariff_by_id(session, int(key_tariff_id)) + if tariff_dict: + subgroup_title = tariff_dict.get("subgroup_title") q = ( select( @@ -278,7 +283,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): return server_ids = [s["id"] for s in servers] - groups_map = {} + groups_map: dict[int, list[str]] = {} if server_ids: r = await session.execute( select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where( @@ -291,7 +296,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): for server in servers: server["special_groups"] = [g for g in groups_map.get(server["id"], []) if g in ALLOWED_GROUP_CODES] - available_servers = [] + available_servers: list[str] = [] tasks = [ asyncio.create_task( check_server_availability( @@ -314,7 +319,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): if subgroup_title and available_servers: available_servers_dict = [s for s in servers if s["server_name"] in available_servers] - filtered_servers = await filter_cluster_by_subgroup(session, available_servers_dict, subgroup_title.strip(), cluster_name) + filtered_servers = await filter_cluster_by_subgroup( + session, + available_servers_dict, + subgroup_title.strip(), + cluster_name, + ) if filtered_servers: available_servers = [s["server_name"] for s in filtered_servers] else: @@ -327,12 +337,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): ) return - if available_servers and tariff_obj: + if available_servers and tariff_dict: special = None - gc = (tariff_obj.group_code or "").lower() if hasattr(tariff_obj, 'group_code') else None + gc = (tariff_dict.get("group_code") or "").lower() if gc and gc in ALLOWED_GROUP_CODES: special = gc - + if special: available_servers_dict = [s for s in servers if s["server_name"] in available_servers] bound_servers = [s for s in available_servers_dict if special in (s.get("special_groups") or [])] @@ -417,13 +427,13 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any, try: expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz) await finalize_key_creation( - tg_id, - expiry_time, - selected_country, - state, - session, - callback_query, - old_key_name, + tg_id=tg_id, + expiry_time=expiry_time, + selected_country=selected_country, + state=state, + session=session, + callback_query=callback_query, + old_key_name=old_key_name, ) finally: fsm_data = await state.get_data() @@ -438,7 +448,7 @@ async def finalize_key_creation( state: FSMContext | None, session: AsyncSession, callback_query: CallbackQuery, - old_key_name: str = None, + old_key_name: str | None = None, tariff_id: int | None = None, ): from_user = callback_query.from_user @@ -456,6 +466,7 @@ async def finalize_key_creation( expiry_time = expiry_time.astimezone(moscow_tz) + old_key_details: dict[str, Any] | None = None if old_key_name: old_key_details = await get_key_details(session, old_key_name) if not old_key_details: @@ -476,24 +487,51 @@ async def finalize_key_creation( email = key_name.lower() expiry_timestamp = int(expiry_time.timestamp() * 1000) - traffic_limit_bytes = None - device_limit = 0 data = await state.get_data() if state else {} is_trial = data.get("is_trial", False) - if data.get("tariff_id") or tariff_id: - tariff_id = data.get("tariff_id") or tariff_id - result = await session.execute(select(Tariff).where(Tariff.id == tariff_id)) - tariff = result.scalar_one_or_none() - if tariff: - if tariff.traffic_limit is not None: - traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3 - if tariff.device_limit is not None: - device_limit = int(tariff.device_limit) - else: - tariff = None + selected_traffic_gb = data.get("config_selected_traffic_gb") + if selected_traffic_gb is None: + selected_traffic_gb = data.get("selected_traffic_limit_gb") - need_vless_key = bool(getattr(tariff, "vless", False)) if tariff else False + selected_device_limit = data.get("config_selected_device_limit") + if selected_device_limit is None: + selected_device_limit = data.get("selected_device_limit") + + if old_key_details: + if selected_traffic_gb is None: + stored_traffic = old_key_details.get("selected_traffic_limit") + if stored_traffic is not None: + selected_traffic_gb = int(stored_traffic) + if selected_device_limit is None: + stored_devices = old_key_details.get("selected_device_limit") + if stored_devices is not None: + selected_device_limit = int(stored_devices) + + price_to_charge = data.get("selected_price_rub") + + effective_tariff_id = data.get("tariff_id") or tariff_id + tariff: dict[str, Any] | None = None + if effective_tariff_id: + tariff_id = int(effective_tariff_id) + tariff = await get_tariff_by_id(session, tariff_id) + + device_limit, traffic_limit_bytes = await get_effective_limits_for_key( + session=session, + tariff_id=tariff_id, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + ) + + if selected_traffic_gb is not None: + traffic_limit_gb = int(selected_traffic_gb) + else: + traffic_limit_gb = int(traffic_limit_bytes / GB) if traffic_limit_bytes else 0 + + if price_to_charge is None and tariff and not old_key_name: + price_to_charge = tariff.get("price_rub") + + need_vless_key = bool(tariff.get("vless")) if tariff else False public_link = None remnawave_link = None @@ -512,7 +550,7 @@ async def finalize_key_creation( cluster_name = cluster_info["cluster_name"] is_full_remnawave = await is_full_remnawave_cluster(cluster_name, session) - if old_key_name: + if old_key_name and old_key_details: old_server_id = old_key_details["server_id"] if old_server_id: result = await session.execute(select(Server).where(Server.server_name == old_server_id)) @@ -545,7 +583,7 @@ async def finalize_key_creation( raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})") expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z" - user_data = { + user_data: dict[str, Any] = { "username": email, "trafficLimitStrategy": "NO_RESET", "expireAt": expire_at, @@ -583,7 +621,8 @@ async def finalize_key_creation( if need_vless_key and not remnawave_link: links = sub.get("links") or [] remnawave_link = next( - (l for l in links if isinstance(l, str) and l.lower().startswith("vless://")), None + (l for l in links if isinstance(l, str) and l.lower().startswith("vless://")), + None, ) if not remnawave_link: @@ -611,9 +650,11 @@ async def finalize_key_creation( session=session, plan=tariff_id, is_trial=is_trial, + total_gb_value=traffic_limit_gb, + device_limit_value=device_limit, ) - subgroup_code = tariff.subgroup_title if tariff and tariff.subgroup_title else None + subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None cluster_all = [ { "server_name": server_info.server_name, @@ -640,7 +681,11 @@ async def finalize_key_creation( public_link = link_to_show if old_key_name: - update_data = {"server_id": selected_country, "key": None, "remnawave_link": None} + update_data: dict[str, Any] = { + "server_id": selected_country, + "key": None, + "remnawave_link": None, + } if public_link and public_link.startswith("vless://"): update_data["key"] = public_link elif public_link and public_link.startswith("http"): @@ -659,17 +704,17 @@ async def finalize_key_creation( remnawave_link=remnawave_link, server_id=selected_country, tariff_id=tariff_id, + selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None, + selected_traffic_limit=int(selected_traffic_gb) if selected_traffic_gb is not None else None, + selected_price_rub=int(price_to_charge) if price_to_charge is not None else None, ) session.add(new_key) if is_trial: trial_status = await get_trial(session, tg_id) if trial_status in [0, -1]: await update_trial(session, tg_id, 1) - if tariff_id: - result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id)) - row = result.scalar_one_or_none() - if row: - await update_balance(session, tg_id, -row) + if not is_trial and price_to_charge: + await update_balance(session, tg_id, -int(price_to_charge)) await session.commit() @@ -696,13 +741,16 @@ async def finalize_key_creation( session=session, ) + tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")) + if panel_type == "remnawave" or is_full_remnawave: if is_vless: builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) else: if use_webapp and webapp_url: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=webapp_url))) - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")) + if tv_button_enabled: + builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")) else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) else: @@ -713,32 +761,35 @@ async def finalize_key_creation( builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) if await process_intercept_key_creation_message( - chat_id=tg_id, session=session, target_message=callback_query + chat_id=tg_id, + session=session, + target_message=callback_query, ): return hook_commands = await process_key_creation_complete( - chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name + chat_id=tg_id, + admin=False, + session=session, + email=email, + key_name=key_name, ) if hook_commands: builder = insert_hook_buttons(builder, hook_commands) - t = tariff.name if tariff else "—" - subgroup_title = tariff.subgroup_title if tariff and tariff.subgroup_title else "" - traffic = tariff.traffic_limit if tariff and tariff.traffic_limit else 0 - devices = tariff.device_limit if tariff and tariff.device_limit else 0 - - key_message_text = key_message_success( - public_link or remnawave_link or "Ссылка не найдена", - tariff_name=t, - traffic_limit=traffic, - device_limit=devices, - subgroup_title=subgroup_title, + key_record = await get_key_details(session, key_name) + final_link_for_message = final_link or (key_record.get("link") if key_record else None) or "Ссылка не найдена" + message_text = await build_key_created_message( + session=session, + key_record=key_record, + final_link=final_link_for_message, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, ) await edit_or_send_message( target_message=callback_query.message, - text=key_message_text, + text=message_text, reply_markup=builder.as_markup(), media_path="img/pic.jpg", ) @@ -777,16 +828,15 @@ async def check_server_availability(server_info: dict, session: AsyncSession) -> logger.info(f"[Ping] Remnawave сервер {server_name} доступен.") return True - else: - xui = AsyncApi( - server_info["api_url"], - username=ADMIN_USERNAME, - password=ADMIN_PASSWORD, - logger=logger, - ) - await asyncio.wait_for(xui.login(), timeout=5.0) - logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.") - return True + xui = AsyncApi( + server_info["api_url"], + username=ADMIN_USERNAME, + password=ADMIN_PASSWORD, + logger=logger, + ) + await asyncio.wait_for(xui.login(), timeout=5.0) + logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.") + return True except TimeoutError: logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.") diff --git a/handlers/keys/key_mode/key_discount_mode.py b/handlers/keys/key_mode/key_discount_mode.py index 36353d20..3292b1ce 100644 --- a/handlers/keys/key_mode/key_discount_mode.py +++ b/handlers/keys/key_mode/key_discount_mode.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from sqlalchemy import select @@ -12,12 +13,11 @@ from database import get_keys, get_tariffs from database.models import Notification from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION from handlers.notifications.notify_kb import build_tariffs_keyboard +from handlers.tariffs.buy.key_tariffs import select_tariff_plan from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX from handlers.utils import format_discount_time_left from logger import logger -from .key_create import select_tariff_plan - router = Router() @@ -54,10 +54,10 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession): expires_at = last_time + timedelta(hours=discount_active_hours) await callback.message.edit_text( - f"🎯 ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!\n\n" f"🌐 Кластер:" - f"💡 Привяжите тариф к кластеру" + "⚠️ Статус: Нет привязанного тарифа\n{cluster_name}\n" - f"⚠️ Статус: Нет привязанного тарифа\n
" - f"💎 Специальные тарифы — доступные только для вас!\n" - f"🚀 Получите максимум возможностей по выгодной цене!\n" - f"\n" + "🎯 ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!\n\n
" + "💎 Специальные тарифы — доступные только для вас!\n" + "🚀 Получите максимум возможностей по выгодной цене!\n" + "\n" f"⏰ Предложение действует всего: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите свой шанс!", reply_markup=builder.as_markup(), ) @@ -74,7 +74,7 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession): @router.callback_query(F.data.startswith("discount_tariff|")) -async def handle_discount_tariff_selection(callback: CallbackQuery, session, state): +async def handle_discount_tariff_selection(callback: CallbackQuery, session: AsyncSession, state: FSMContext): try: tariff_id = int(callback.data.split("|")[1]) fake_callback = CallbackQuery.model_construct( @@ -120,16 +120,17 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession): builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + expires_at = last_time + timedelta(hours=discount_active_hours) await callback.message.edit_text( - f"🎯 УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!\n\n
" - f"💎 Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой — только для вас!\n" - f"🚀 Уникальные условия — получите максимум преимуществ по минимальной цене!\n" - f"\n" - f"⏰ Время ограничено: {format_discount_time_left(last_time, discount_active_hours)} — не упустите шанс!", + "🎯 УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!\n\n
" + "💎 Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой — только для вас!\n" + "🚀 Уникальные условия — получите максимум преимуществ по минимальной цене!\n" + "\n" + f"⏰ Время ограничено: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите шанс!", reply_markup=builder.as_markup(), ) else: - tariffs = await get_tariffs(session, group_code="discounts_max") + tariffs = await get_tariffs(session=session, group_code="discounts_max") if not tariffs: await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.") return diff --git a/handlers/keys/key_renew.py b/handlers/keys/key_renew.py index ad17c311..f707565d 100644 --- a/handlers/keys/key_renew.py +++ b/handlers/keys/key_renew.py @@ -15,12 +15,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot import bot from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW from core.bootstrap import NOTIFICATIONS_CONFIG +from core.settings.tariffs_config import normalize_tariff_config from database import ( check_tariff_exists, get_balance, get_key_by_server, get_key_details, get_tariff_by_id, + reset_key_current_limits_to_selected, + save_key_config_with_mode, update_balance, update_key_expiry, ) @@ -31,6 +34,7 @@ from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT from handlers.keys.operations import renew_key_in_cluster from handlers.payments.currency_rates import format_for_user from handlers.payments.fast_payment_flow import try_fast_payment_flow +from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key from handlers.texts import ( DISCOUNT_OFFER_MESSAGE, DISCOUNT_OFFER_STEP2, @@ -58,6 +62,7 @@ moscow_tz = pytz.timezone("Europe/Moscow") @router.callback_query(F.data.startswith("renew_key|")) async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): + """Обрабатывает нажатие кнопки продления конкретного ключа.""" tg_id = callback_query.message.chat.id key_name = callback_query.data.split("|")[1] @@ -125,9 +130,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo current_tariff = await get_tariff_by_id(session, tariff_id) forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"] - additional_groups = await process_renewal_forbidden_groups( - chat_id=tg_id, admin=False, session=session - ) + additional_groups = await process_renewal_forbidden_groups(chat_id=tg_id, admin=False, session=session) forbidden_groups.extend(additional_groups) if current_tariff["group_code"] not in forbidden_groups: @@ -164,9 +167,13 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo for t in grouped_tariffs.get(None, []): price_text = await format_for_user(session, tg_id, t["price_rub"], language_code) + if t.get("configurable"): + button_text = f"{t['name']} — от {price_text}" + else: + button_text = f"{t['name']} — {price_text}" builder.row( InlineKeyboardButton( - text=f"{t['name']} — {price_text}", + text=button_text, callback_data=f"renew_plan|{t['id']}", ) ) @@ -231,6 +238,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo @router.callback_query(F.data.startswith("renew_subgroup|")) async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + """Показывает тарифы внутри выбранной подгруппы при продлении.""" try: subgroup_hash = callback.data.split("|")[1] @@ -317,9 +325,13 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont builder = InlineKeyboardBuilder() for t in filtered: price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code) + if t.get("configurable"): + button_text = f"{t['name']} — от {price_txt}" + else: + button_text = f"{t['name']} — {price_txt}" builder.row( InlineKeyboardButton( - text=f"{t['name']} — {price_txt}", + text=button_text, callback_data=f"renew_plan|{t['id']}", ) ) @@ -330,9 +342,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont hook_builder = InlineKeyboardBuilder() hook_builder.attach(builder) - hook_commands = await process_renew_tariffs( - chat_id=callback.from_user.id, admin=False, session=session - ) + hook_commands = await process_renew_tariffs(chat_id=callback.from_user.id, admin=False, session=session) if hook_commands: hook_builder = insert_hook_buttons(hook_builder, hook_commands) @@ -362,6 +372,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont @router.callback_query(F.data.startswith("renew_plan|")) async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any): + """Обрабатывает выбор конкретного тарифа для продления.""" tg_id = callback_query.from_user.id tariff_id = int(callback_query.data.split("|")[1]) @@ -391,7 +402,6 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC return duration_days = tariff["duration_days"] - cost = tariff["price_rub"] total_gb = tariff["traffic_limit"] or 0 record = await get_key_by_server(session, tg_id, client_id) @@ -409,8 +419,47 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC else: new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000) + if tariff.get("configurable"): + selected_devices_db = record.get("selected_device_limit") + selected_traffic_db = record.get("selected_traffic_limit") + + if selected_devices_db is not None: + config_selected_devices = int(selected_devices_db) + else: + base_devices = tariff.get("device_limit") + config_selected_devices = int(base_devices) if base_devices is not None else None + + if selected_traffic_db is not None: + config_selected_traffic_gb = int(selected_traffic_db) + else: + base_traffic_gb = tariff.get("traffic_limit") + config_selected_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None + + await state.update_data( + renew_mode="renew", + renew_key_name=email, + renew_client_id=client_id, + renew_tariff_id=tariff_id, + renew_new_expiry_time=new_expiry_time, + config_selected_device_limit=config_selected_devices, + config_selected_traffic_gb=config_selected_traffic_gb, + ) + + from handlers.tariffs.buy.key_tariffs import start_tariff_config + + await start_tariff_config( + callback_query=callback_query, + state=state, + session=session, + tariff_id=tariff_id, + ) + return + balance = round(await get_balance(session, tg_id), 2) - cost = round(cost, 2) + + stored_price = record.get("selected_price_rub") + base_cost = tariff["price_rub"] + cost = round(stored_price if stored_price is not None else base_cost, 2) if balance < cost: required_amount = ceil(cost - balance) @@ -466,7 +515,105 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}") +@router.callback_query(F.data.startswith("cfg_renew_confirm|")) +async def handle_renew_config_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): + """Подтверждает выбор параметров тарифа при продлении.""" + tg_id = callback_query.from_user.id + + try: + data = await state.get_data() + + client_id = data.get("renew_client_id") + email = data.get("renew_key_name") + new_expiry_time = data.get("renew_new_expiry_time") + tariff_id = data.get("renew_tariff_id") + selected_devices = data.get("config_selected_device_limit") + selected_traffic_gb = data.get("config_selected_traffic_gb") + + if not client_id or not email or not new_expiry_time or not tariff_id: + await callback_query.message.answer("❌ Данные для продления не найдены.") + return + + from handlers.tariffs.buy.key_tariffs import calculate_config_price + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff or not tariff.get("configurable"): + await callback_query.message.answer("❌ Тариф не найден или не поддерживает настройку.") + return + + final_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None else None, + selected_traffic_gb=int(selected_traffic_gb) if selected_traffic_gb is not None else None, + ) + + balance = round(await get_balance(session, tg_id), 2) + cost = round(final_price, 2) + + if balance < cost: + required_amount = ceil(cost - balance) + + if USE_NEW_PAYMENT_FLOW: + handled = await try_fast_payment_flow( + callback_query, + session, + state, + tg_id=tg_id, + temp_key="waiting_for_renewal_payment", + temp_payload={ + "tariff_id": int(tariff_id), + "client_id": client_id, + "cost": cost, + "required_amount": required_amount, + "new_expiry_time": int(new_expiry_time), + "total_gb": int(selected_traffic_gb or 0), + "email": email, + "selected_device_limit": selected_devices, + "selected_traffic_limit": selected_traffic_gb, + "selected_price_rub": int(final_price), + }, + required_amount=required_amount, + ) + if handled: + return + + language_code = getattr(callback_query.from_user, "language_code", None) + required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + await edit_or_send_message( + target_message=callback_query.message, + text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text), + reply_markup=builder.as_markup(), + ) + return + + logger.info(f"[RENEW_CONFIG] Продление ключа {client_id} с конфигурацией для пользователя {tg_id}") + + await complete_key_renewal( + session=session, + tg_id=tg_id, + client_id=client_id, + email=email, + new_expiry_time=int(new_expiry_time), + total_gb=int(selected_traffic_gb or 0), + cost=cost, + callback_query=callback_query, + tariff_id=int(tariff_id), + selected_device_limit=int(selected_devices) if selected_devices is not None else None, + selected_traffic_limit=int(selected_traffic_gb) if selected_traffic_gb is not None else None, + selected_price_rub=int(final_price), + ) + + except Exception as e: + logger.error(f"[RENEW_CONFIG] Ошибка при продлении по конфигурации для пользователя {tg_id}: {e}") + await callback_query.message.answer("❌ Произошла ошибка при продлении. Попробуйте позже.") + + async def resolve_cluster_name(session: AsyncSession, server_or_cluster: str) -> str | None: + """Определяет имя кластера по server_id или cluster_name.""" result = await session.execute(select(Server).where(Server.cluster_name == server_or_cluster).limit(1)) server = result.scalars().first() if server: @@ -487,7 +634,11 @@ async def complete_key_renewal( cost: float, callback_query: CallbackQuery | None, tariff_id: int, + selected_device_limit: int | None = None, + selected_traffic_limit: int | None = None, + selected_price_rub: int | None = None, ): + """Продлевает подписку, обновляет лимиты и данные в БД.""" try: logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)") @@ -511,6 +662,63 @@ async def complete_key_renewal( logger.error(f"[Error] Тариф с id={tariff_id} не найден.") return + key_info = await get_key_details(session, email) + if not key_info: + logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.") + return + + selected_device_limit_db = key_info.get("selected_device_limit") + selected_traffic_limit_db = key_info.get("selected_traffic_limit") + + final_device_limit = ( + int(selected_device_limit) if selected_device_limit is not None else selected_device_limit_db + ) + final_traffic_limit = ( + int(selected_traffic_limit) if selected_traffic_limit is not None else selected_traffic_limit_db + ) + + selected_traffic_gb_effective = int(final_traffic_limit) if final_traffic_limit is not None else None + selected_device_limit_effective = int(final_device_limit) if final_device_limit is not None else None + + device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff_id), + selected_device_limit=selected_device_limit_effective, + selected_traffic_gb=selected_traffic_gb_effective, + ) + traffic_limit_gb_effective = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0 + total_gb = traffic_limit_gb_effective + + cfg = normalize_tariff_config(tariff) + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + device_int_options: list[int] = [] + for value in raw_device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in raw_traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + has_device_choice = len(device_int_options) > 1 + has_traffic_choice = len(traffic_int_options) > 1 + + if selected_price_rub is None: + stored_price = key_info.get("selected_price_rub") + if stored_price is not None: + final_price_rub = int(stored_price) + else: + final_price_rub = int(cost) + else: + final_price_rub = int(selected_price_rub) + formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M") formatted_expiry_date = formatted_expiry_date.replace( datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"), @@ -519,17 +727,12 @@ async def complete_key_renewal( response_message = get_renewal_message( tariff_name=tariff["name"], - traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0, - device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0, + traffic_limit=traffic_limit_gb_effective, + device_limit=device_limit_effective, expiry_date=formatted_expiry_date, subgroup_title=tariff.get("subgroup_title", ""), ) - key_info = await get_key_details(session, email) - if not key_info: - logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.") - return - current_subgroup = None try: current_tariff_id = key_info.get("tariff_id") @@ -556,7 +759,7 @@ async def complete_key_renewal( new_expiry_time=new_expiry_time, total_gb=total_gb, session=session, - hwid_device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0, + hwid_device_limit=device_limit_effective, reset_traffic=True, target_subgroup=target_subgroup, old_subgroup=old_subgroup, @@ -570,6 +773,20 @@ async def complete_key_renewal( await session.execute(update(Key).where(Key.email == email).values(tariff_id=tariff_id)) await update_balance(session, tg_id, -cost) + if tariff.get("configurable"): + await save_key_config_with_mode( + session=session, + email=email, + selected_devices=final_device_limit, + selected_traffic_gb=final_traffic_limit, + total_price=int(final_price_rub), + has_device_choice=has_device_choice, + has_traffic_choice=has_traffic_choice, + config_mode="renewal", + ) + if has_device_choice or has_traffic_choice: + await reset_key_current_limits_to_selected(session, effective_client_id) + builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}")) hook_commands = await process_renewal_complete( diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py index 12644400..4e434a95 100644 --- a/handlers/keys/key_view.py +++ b/handlers/keys/key_view.py @@ -4,7 +4,6 @@ import os import re from datetime import datetime -from typing import Any import pytz @@ -13,7 +12,7 @@ from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlalchemy import update +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from config import ( @@ -28,9 +27,12 @@ from config import ( USE_COUNTRY_SELECTION, ) from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG -from database import get_key_details, get_keys, get_servers, get_tariff_by_id +from database import get_key_details, get_keys, get_servers from database.models import Key from handlers.buttons import ( + ADDONS_BUTTON_DEVICES, + ADDONS_BUTTON_DEVICES_TRAFFIC, + ADDONS_BUTTON_TRAFFIC, ALIAS, BACK, CHANGE_LOCATION, @@ -45,6 +47,7 @@ from handlers.buttons import ( TV_BUTTON, UNFREEZE, ) +from handlers.tariffs.tariff_display import GB, get_key_tariff_addons_state from handlers.texts import ( DAYS_LEFT_MESSAGE, FROZEN_SUBSCRIPTION_MSG, @@ -82,7 +85,10 @@ class RenameKeyState(StatesGroup): @router.callback_query(F.data == "view_keys") @router.message(F.text == "/subs") -async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any): +async def process_callback_or_message_view_keys( + callback_query_or_message: Message | CallbackQuery, + session: AsyncSession, +): if isinstance(callback_query_or_message, CallbackQuery): target_message = callback_query_or_message.message else: @@ -113,7 +119,7 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa await target_message.answer(text=error_message) -async def build_keys_response(records, session): +async def build_keys_response(records: list[Key] | None, session: AsyncSession): builder = InlineKeyboardBuilder() if records: @@ -133,13 +139,13 @@ async def build_keys_response(records, session): formatted_date_full = "без срока действия" is_vless = False - if hasattr(record, "tariff_id") and record.tariff_id: + if getattr(record, "tariff_id", None): try: - tariff = await get_tariff_by_id(session, record.tariff_id) - if tariff and tariff.get("vless"): - is_vless = True + from handlers.tariffs.tariff_display import resolve_vless_enabled + + is_vless = await resolve_vless_enabled(session, int(record.tariff_id)) except Exception: - pass + is_vless = False icon = "📶" if is_vless else "🔑" @@ -213,23 +219,25 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A @router.callback_query(F.data.startswith("view_key|")) -async def process_callback_view_key(callback_query: CallbackQuery, session: Any): +async def process_callback_view_key(callback_query: CallbackQuery, session: AsyncSession): key_name = callback_query.data.split("|")[1] image_path = os.path.join("img", "pic_view.jpg") await render_key_info(callback_query.message, session, key_name, image_path) -async def render_key_info(message: Message, session: Any, key_name: str, image_path: str): +async def build_key_view_payload(session: AsyncSession, key_name: str): record = await get_key_details(session, key_name) if not record: - await message.answer("Информация о подписке не найдена.") - return + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + return "Информация о подписке не найдена.", builder.as_markup(), False + + db_key_result = await session.execute(select(Key).where(Key.email == key_name)) + db_key: Key | None = db_key_result.scalar_one_or_none() is_frozen = record["is_frozen"] client_id = record.get("client_id") - remnawave_link = record.get("remnawave_link") - key = record.get("key") - final_link = key or remnawave_link + final_link = record.get("link") builder = InlineKeyboardBuilder() @@ -237,13 +245,7 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p builder.row(InlineKeyboardButton(text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}")) builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - await edit_or_send_message( - target_message=message, - text=FROZEN_SUBSCRIPTION_MSG, - reply_markup=builder.as_markup(), - media_path=image_path, - ) - return + return FROZEN_SUBSCRIPTION_MSG, builder.as_markup(), True expiry_time = record["expiry_time"] server_name = record["server_id"] @@ -267,12 +269,33 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p ) is_full_task = asyncio.create_task(is_full_remnawave_cluster(server_name, session)) - tariff_task = ( - asyncio.create_task(get_tariff_by_id(session, record["tariff_id"])) if record.get("tariff_id") else None - ) + + tariff_name = "" + subgroup_title = "" + traffic_limit_gb = 0 + device_limit = 0 + vless_enabled = False + is_tariff_configurable = False + addons_devices_enabled = False + addons_traffic_enabled = False + + if record.get("tariff_id"): + ( + tariff_name, + subgroup_title, + traffic_limit_gb, + device_limit, + vless_enabled, + is_tariff_configurable, + addons_devices_enabled, + addons_traffic_enabled, + ) = await get_key_tariff_addons_state( + session=session, + key_record=record, + db_key=db_key, + ) is_full_remnawave = await is_full_task - tariff = await tariff_task if tariff_task else None hwid_count = 0 remna_used_gb = None @@ -290,24 +313,16 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p user_data = await api.get_user_by_uuid(client_id) if user_data: used_bytes = user_data.get("usedTrafficBytes", 0) - remna_used_gb = round(used_bytes / 1073741824, 1) + remna_used_gb = round(used_bytes / GB, 1) + traffic_limit_bytes_actual = user_data.get("trafficLimitBytes") + if traffic_limit_bytes_actual is not None: + if traffic_limit_bytes_actual > 0: + traffic_limit_gb = int(traffic_limit_bytes_actual / GB) + else: + traffic_limit_gb = 0 except Exception as error: logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {error}") - tariff_name = "" - traffic_limit = 0 - device_limit = 0 - subgroup_title = "" - vless_enabled = False - if tariff: - tariff_name = tariff["name"] - traffic_limit = tariff.get("traffic_limit", 0) - device_limit = tariff.get("device_limit", 0) - subgroup_title = tariff.get("subgroup_title", "") - vless_enabled = bool(tariff.get("vless")) - - tariff_duration = tariff_name - country_selection_enabled = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)) remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)) happ_cryptolink_enabled = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)) @@ -319,8 +334,8 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p server_name, server_name if country_selection_enabled else None, hwid_count=hwid_count if device_limit is not None else 0, - tariff_name=tariff_duration, - traffic_limit=traffic_limit, + tariff_name=tariff_name, + traffic_limit=traffic_limit_gb, device_limit=device_limit, subgroup_title=subgroup_title, is_remnawave=is_full_remnawave, @@ -335,12 +350,15 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p session=session, ) + tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")) + if is_full_remnawave and final_link and use_webapp and not happ_cryptolink_enabled: if vless_enabled: builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")) + if tv_button_enabled: + builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")) else: if vless_enabled: builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) @@ -349,6 +367,15 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}")) + if is_tariff_configurable and (addons_devices_enabled or addons_traffic_enabled): + if addons_devices_enabled and addons_traffic_enabled: + addons_text = ADDONS_BUTTON_DEVICES_TRAFFIC + elif addons_devices_enabled: + addons_text = ADDONS_BUTTON_DEVICES + else: + addons_text = ADDONS_BUTTON_TRAFFIC + builder.row(InlineKeyboardButton(text=addons_text, callback_data=f"key_addons|{key_name}")) + hwid_reset_enabled = bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)) qrcode_enabled = bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)) delete_key_enabled = bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)) @@ -370,19 +397,30 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + module_buttons = await process_view_key_menu(key_name=key_name, session=session) builder = insert_hook_buttons(builder, module_buttons) + return response_message, builder.as_markup(), False + + +async def build_key_view_message(session: AsyncSession, email: str): + text, reply_markup, _ = await build_key_view_payload(session, email) + return text, reply_markup + + +async def render_key_info(message: Message, session: AsyncSession, key_name: str, image_path: str): + text, reply_markup, _ = await build_key_view_payload(session, key_name) await edit_or_send_message( target_message=message, - text=response_message, - reply_markup=builder.as_markup(), + text=text, + reply_markup=reply_markup, media_path=image_path, ) @router.callback_query(F.data.startswith("reset_hwid|")) -async def handle_reset_hwid(callback_query: CallbackQuery, session: Any): +async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession): key_name = callback_query.data.split("|")[1] record_task = asyncio.create_task(get_key_details(session, key_name)) @@ -420,7 +458,10 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any): await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True) if await process_after_hwid_reset( - chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name + chat_id=callback_query.from_user.id, + admin=False, + session=session, + key_name=key_name, ): builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) @@ -428,7 +469,8 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any): await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=builder.as_markup()) else: await callback_query.message.edit_caption( - caption="✅ Устройства сброшены", reply_markup=builder.as_markup() + caption="✅ Устройства сброшены", + reply_markup=builder.as_markup(), ) return diff --git a/handlers/keys/operations/__init__.py b/handlers/keys/operations/__init__.py index e9da8a9d..1a31452d 100644 --- a/handlers/keys/operations/__init__.py +++ b/handlers/keys/operations/__init__.py @@ -1,5 +1,3 @@ -# handlers/keys/operations/__init__.py - from .creation import create_client_on_server, create_key_on_cluster from .deletion import delete_key_from_cluster from .renewal import renew_key_in_cluster diff --git a/handlers/keys/operations/aggregated_links.py b/handlers/keys/operations/aggregated_links.py index 90bbc56b..c4b7b093 100644 --- a/handlers/keys/operations/aggregated_links.py +++ b/handlers/keys/operations/aggregated_links.py @@ -3,8 +3,8 @@ import asyncio from sqlalchemy.ext.asyncio import AsyncSession from config import HAPP_CRYPTOLINK, LEGACY_LINKS, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE -from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id from core.bootstrap import MODES_CONFIG +from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id from logger import logger from panels._3xui import get_vless_link_for_client, get_xui_instance from panels.remnawave import RemnawaveAPI diff --git a/handlers/keys/operations/creation.py b/handlers/keys/operations/creation.py index 5608ca82..d9ee599a 100644 --- a/handlers/keys/operations/creation.py +++ b/handlers/keys/operations/creation.py @@ -5,10 +5,9 @@ from datetime import datetime from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession -from config import HAPP_CRYPTOLINK, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE +from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE from database import get_servers, get_tariff_by_id, store_key from database.models import User -from core.bootstrap import MODES_CONFIG from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit from hooks.processors import process_extract_cryptolink_from_result from logger import ( @@ -60,16 +59,35 @@ async def create_key_on_cluster( tariff = None subgroup_title = None need_vless_key = False + + traffic_limit_bytes_value = 0 + device_limit_value = 0 + if plan is not None: tariff = await get_tariff_by_id(session, plan) if not tariff: raise ValueError(f"Тариф с id={plan} не найден.") + if traffic_limit_bytes is None: - traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None + raw_traffic_limit = tariff.get("traffic_limit") + if raw_traffic_limit: + traffic_limit_bytes_value = int(raw_traffic_limit) * 1024 * 1024 * 1024 + else: + traffic_limit_bytes_value = 0 + else: + traffic_limit_bytes_value = int(traffic_limit_bytes) + if hwid_limit is None: - hwid_limit = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0 + raw_device_limit = tariff.get("device_limit") + device_limit_value = int(raw_device_limit) if raw_device_limit is not None else 0 + else: + device_limit_value = int(hwid_limit) + subgroup_title = tariff.get("subgroup_title") need_vless_key = bool(tariff.get("vless")) + else: + traffic_limit_bytes_value = int(traffic_limit_bytes or 0) + device_limit_value = int(hwid_limit or 0) if subgroup_title: subgroup_servers = [s for s in enabled_servers if subgroup_title in s.get("tariff_subgroups", [])] @@ -77,7 +95,8 @@ async def create_key_on_cluster( enabled_servers = subgroup_servers else: logger.warning( - f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. Использую весь кластер." + f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. " + f"Использую весь кластер." ) special = None @@ -87,13 +106,15 @@ async def create_key_on_cluster( gc = (tariff.get("group_code") or "").lower() if gc in ALLOWED_GROUP_CODES: special = gc + if special: bound_servers = [s for s in enabled_servers if special in (s.get("special_groups") or [])] if bound_servers: enabled_servers = bound_servers else: logger.info( - f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. Использую весь кластер." + f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. " + f"Использую весь кластер." ) remnawave_servers = [ @@ -129,6 +150,7 @@ async def create_key_on_cluster( short_uuid = None if remnawave_link and "/" in remnawave_link: short_uuid = remnawave_link.rstrip("/").split("/")[-1] + user_data = { "username": email, "trafficLimitStrategy": "NO_RESET", @@ -137,18 +159,22 @@ async def create_key_on_cluster( "activeInternalSquads": inbound_ids, "uuid": client_id, } - if traffic_limit_bytes and traffic_limit_bytes > 0: - user_data["trafficLimitBytes"] = traffic_limit_bytes * 1024 * 1024 * 1024 + + if traffic_limit_bytes_value and traffic_limit_bytes_value > 0: + user_data["trafficLimitBytes"] = traffic_limit_bytes_value + if short_uuid: user_data["shortUuid"] = short_uuid - user_data["hwidDeviceLimit"] = hwid_limit + + user_data["hwidDeviceLimit"] = device_limit_value + logger.debug(f"{PANEL_REMNA} Данные для создания клиента: {user_data}") result = await remna.create_user(user_data) if result: remnawave_created = True remnawave_client_id = result.get("uuid") remnawave_link_value = result.get("subscriptionUrl") - + remnawave_key = None if need_vless_key: try: @@ -167,7 +193,7 @@ async def create_key_on_cluster( ) if crypto_link: remnawave_key = crypto_link - + logger.info(f"{PANEL_REMNA} Пользователь создан: {result}") else: logger.warning(f"{PANEL_REMNA} Нет inbound_id у серверов") @@ -189,25 +215,27 @@ async def create_key_on_cluster( plan=plan, session=session, is_trial=is_trial, + total_traffic_limit_bytes=traffic_limit_bytes_value, + device_limit_value=device_limit_value, ) else: - await asyncio.gather( - *[ - create_client_on_server( - server, - tg_id, - final_client_id, - email, - expiry_timestamp, - semaphore, - plan=plan, - session=session, - is_trial=is_trial, - ) - for server in xui_servers - ], - return_exceptions=True, - ) + tasks = [ + create_client_on_server( + server, + tg_id, + final_client_id, + email, + expiry_timestamp, + semaphore, + plan=plan, + session=session, + is_trial=is_trial, + total_traffic_limit_bytes=traffic_limit_bytes_value, + device_limit_value=device_limit_value, + ) + for server in xui_servers + ] + await asyncio.gather(*tasks, return_exceptions=True) cluster_all = enabled_servers subgroup_code = subgroup_title if subgroup_title else None @@ -254,12 +282,15 @@ async def create_client_on_server( email: str, expiry_timestamp: int, semaphore: asyncio.Semaphore, - plan: int = None, - session=None, + plan: int | None = None, + session: AsyncSession | None = None, is_trial: bool = False, + total_traffic_limit_bytes: int = 0, + device_limit_value: int = 0, ): logger.debug( - f"{PANEL_XUI} [Client] Вход в create_client_on_server: сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}" + f"{PANEL_XUI} [Client] Вход в create_client_on_server: " + f"сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}" ) async with semaphore: @@ -278,23 +309,27 @@ async def create_client_on_server( unique_email = email sub_id = unique_email - total_gb_value = 0 - device_limit_value = 0 - - if plan is not None: + if plan is not None and (total_traffic_limit_bytes == 0 or device_limit_value == 0): tariff = await get_tariff_by_id(session, plan) logger.debug(f"{PANEL_XUI} [Tariff Debug] Получен тариф: {tariff}") if not tariff: raise ValueError(f"{PANEL_XUI} Тариф с id={plan} не найден.") - total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0 - device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0 + if total_traffic_limit_bytes == 0: + raw_limit = tariff.get("traffic_limit") + base_gb = int(raw_limit) if raw_limit else 0 + total_traffic_limit_bytes = base_gb * 1024 * 1024 * 1024 + + if device_limit_value == 0: + raw_device_limit = tariff.get("device_limit") + device_limit_value = int(raw_device_limit) if raw_device_limit is not None else 0 try: logger.debug( - f"{PANEL_XUI} [Client] Вызов add_client: email={email}, client_id={client_id}, GB={total_gb_value}, Devices={device_limit_value}" + f"{PANEL_XUI} [Client] Вызов add_client: email={email}, client_id={client_id}, " + f"bytes={total_traffic_limit_bytes}, Devices={device_limit_value}" ) - traffic_limit_bytes = total_gb_value * 1024 * 1024 * 1024 + traffic_limit_bytes = total_traffic_limit_bytes await add_client( xui, ClientConfig( diff --git a/handlers/keys/operations/renewal.py b/handlers/keys/operations/renewal.py index b10641c2..1605fd35 100644 --- a/handlers/keys/operations/renewal.py +++ b/handlers/keys/operations/renewal.py @@ -14,6 +14,7 @@ from database import ( update_key_expiry, update_key_link, ) +from hooks.processors import process_get_cryptolink_after_renewal from logger import ( CLOGGER as logger, PANEL_REMNA, @@ -24,7 +25,6 @@ from panels.remnawave import RemnawaveAPI from .aggregated_links import make_aggregated_link from .subgroup_migration import migrate_between_subgroups -from hooks.processors import process_get_cryptolink_after_renewal async def resolve_cluster(session: AsyncSession, cluster_id: str): @@ -293,7 +293,8 @@ async def renew_key_in_cluster( remna_link_override = None if remna_ok and cluster_scope: remnawave_nodes = [ - s for s in cluster_scope + s + for s in cluster_scope if str(s.get("panel_type", "3x-ui")).lower() == "remnawave" and s.get("inbound_id") ] if remnawave_nodes: @@ -321,7 +322,7 @@ async def renew_key_in_cluster( await update_key_link(session, email, key_link) except Exception as le: logger.warning(f"[Link] ошибка генерации/сохранения после продления: {le}") - + return True return False diff --git a/handlers/keys/operations/subgroup_migration.py b/handlers/keys/operations/subgroup_migration.py index e80c8f0d..cb8a1325 100644 --- a/handlers/keys/operations/subgroup_migration.py +++ b/handlers/keys/operations/subgroup_migration.py @@ -5,8 +5,8 @@ from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE -from database import filter_cluster_by_subgroup, update_key_client_id from core.bootstrap import MODES_CONFIG +from database import filter_cluster_by_subgroup, update_key_client_id from logger import ( CLOGGER as logger, PANEL_REMNA, @@ -33,6 +33,13 @@ async def ensure_on_remnawave( if not servers: return None, None + if total_gb is None: + total_gb = 0 + if hwid_device_limit is None: + hwid_device_limit = 0 + else: + hwid_device_limit = int(hwid_device_limit) + inbounds = [s.get("inbound_id") for s in servers if s.get("inbound_id")] api = RemnawaveAPI(servers[0]["api_url"]) @@ -77,8 +84,8 @@ async def ensure_on_remnawave( } if traffic_bytes > 0: payload["trafficLimitBytes"] = traffic_bytes - if hwid_device_limit is not None: - payload["hwidDeviceLimit"] = hwid_device_limit + + payload["hwidDeviceLimit"] = hwid_device_limit created = await api.create_user(payload) new_uuid = created.get("uuid") if isinstance(created, dict) else None diff --git a/handlers/keys/operations/update.py b/handlers/keys/operations/update.py index 67d16b7d..3af8cb8b 100644 --- a/handlers/keys/operations/update.py +++ b/handlers/keys/operations/update.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE from database import filter_cluster_by_subgroup, get_servers, get_tariff_by_id, store_key from database.models import Key, Tariff +from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key from handlers.utils import get_least_loaded_cluster from logger import ( CLOGGER as logger, @@ -61,7 +62,6 @@ async def update_key_on_cluster( xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"] remnawave_client_id = None - remnawave_key = None remnawave_link_value = None if remnawave_servers: @@ -106,8 +106,7 @@ async def update_key_on_cluster( if result: remnawave_client_id = result.get("uuid") remnawave_link_value = result.get("subscriptionUrl") - remnawave_key = None - + logger.info(f"{PANEL_REMNA} Клиент заново создан, uuid={remnawave_client_id}") else: logger.error(f"{PANEL_REMNA} Ошибка создания клиента") @@ -181,7 +180,7 @@ async def update_subscription( remnawave_link: str = None, ) -> None: result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email)) - record = result.scalar_one_or_none() + record: Key | None = result.scalar_one_or_none() if not record: raise ValueError(f"The key {email} does not exist in database") @@ -193,6 +192,10 @@ async def update_subscription( remnawave_link = remnawave_link or record.remnawave_link public_link = f"{PUBLIC_LINK}{email}/{tg_id}" + selected_device_limit = getattr(record, "selected_device_limit", None) + selected_traffic_limit = getattr(record, "selected_traffic_limit", None) + selected_price_rub = getattr(record, "selected_price_rub", None) + tariff = None subgroup_code = getattr(record, "subgroup_code", None) @@ -243,10 +246,20 @@ async def update_subscription( return cluster_servers = prefiltered - traffic_limit = None - device_limit = None - if tariff: - traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None + traffic_limit_gb = None + device_limit = 0 + + if tariff and tariff_id: + device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff_id), + selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None, + selected_traffic_gb=int(selected_traffic_limit) if selected_traffic_limit is not None else None, + ) + device_limit = int(device_limit_effective or 0) + traffic_limit_gb = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else None + elif tariff: + traffic_limit_gb = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0 new_client_id, remnawave_link_value = await update_key_on_cluster( @@ -256,7 +269,7 @@ async def update_subscription( expiry_time=expiry_time, cluster_id=new_cluster_id, session=session, - traffic_limit=traffic_limit, + traffic_limit=traffic_limit_gb, device_limit=device_limit, remnawave_link=remnawave_link, subgroup_code=subgroup_code, @@ -287,4 +300,7 @@ async def update_subscription( server_id=new_cluster_id, tariff_id=tariff_id, alias=alias, + selected_device_limit=selected_device_limit, + selected_traffic_limit=selected_traffic_limit, + selected_price_rub=selected_price_rub, ) diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py index 6c6a1b41..ffba0e0a 100644 --- a/handlers/notifications/general_notifications.py +++ b/handlers/notifications/general_notifications.py @@ -26,24 +26,27 @@ from database import ( add_notification, check_notification_time, check_notifications_bulk, - check_tariff_exists, delete_key, delete_notification, get_all_keys, get_balance, get_last_notification_time, - get_tariff_by_id, - get_tariffs_for_cluster, update_balance, update_key_expiry, update_key_tariff, ) +from database.tariffs import ( + check_tariff_exists, + get_tariff_by_id, + get_tariffs_for_cluster, +) from handlers.keys.operations import delete_key_from_cluster, renew_key_in_cluster from handlers.notifications.notify_kb import ( build_change_tariff_kb, build_notification_expired_kb, build_notification_kb, ) +from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key from handlers.texts import ( KEY_CANNOT_RENEW_CURRENT, KEY_DELETED_MSG, @@ -382,6 +385,27 @@ async def handle_expired_keys( tariff = tariffs[0] if tariffs else None if tariff and balance >= tariff["price_rub"]: + selected_device_limit = getattr(key, "selected_device_limit", None) + selected_traffic_limit = getattr(key, "selected_traffic_limit", None) + selected_traffic_gb = int(selected_traffic_limit) if selected_traffic_limit is not None else None + + device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff["id"]), + selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None, + selected_traffic_gb=selected_traffic_gb, + ) + traffic_limit_gb_effective = ( + int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0 + ) + + standard_caption = get_renewal_message( + tariff_name=tariff.get("name", ""), + traffic_limit=traffic_limit_gb_effective, + device_limit=device_limit_effective, + subgroup_title=tariff.get("subgroup_title", ""), + ) + await process_auto_renew_or_notify( bot, session, @@ -389,12 +413,7 @@ async def handle_expired_keys( notification_id, 1, "notify_expired.jpg", - get_renewal_message( - tariff_name=tariff.get("name", ""), - traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0, - device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0, - subgroup_title=tariff.get("subgroup_title", ""), - ), + standard_caption, ) except Exception as error: @@ -485,6 +504,7 @@ async def process_auto_renew_or_notify( standard_photo: str, standard_caption: str, ): + """Пытается автопродлить ключ или отправить уведомление.""" tg_id = key.tg_id email = key.email or "" renew_notification_id = f"{email}_renew" @@ -508,33 +528,39 @@ async def process_auto_renew_or_notify( selected_tariff = None - if not tariff_id: - selected_tariff = None - else: - if await check_tariff_exists(conn, tariff_id): - current_tariff = await get_tariff_by_id(conn, tariff_id) + if tariff_id and await check_tariff_exists(conn, tariff_id): + current_tariff = await get_tariff_by_id(conn, tariff_id) - forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"] + forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"] + try: + hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn) + for hook_result in hook_results: + additional_groups = hook_result.get("additional_groups", []) + forbidden_groups.extend(additional_groups) + except Exception as error: + logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}") - try: - hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn) - for hook_result in hook_results: - additional_groups = hook_result.get("additional_groups", []) - forbidden_groups.extend(additional_groups) - except Exception as error: - logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}") - - if current_tariff["group_code"] in forbidden_groups: - selected_tariff = None - elif balance >= current_tariff["price_rub"]: + if current_tariff and current_tariff["group_code"] not in forbidden_groups: + stored_price = getattr(key, "selected_price_rub", None) + renewal_cost = float(stored_price) if stored_price is not None else float(current_tariff["price_rub"]) + if balance >= renewal_cost: selected_tariff = current_tariff + else: + selected_tariff = None else: selected_tariff = None + else: + selected_tariff = None if not selected_tariff: - expiry_data = await prepare_key_expiry_data(key, conn, int(datetime.now(moscow_tz).timestamp() * 1000)) + expiry_data = await prepare_key_expiry_data( + key, + conn, + int(datetime.now(moscow_tz).timestamp() * 1000), + ) use_change_tariff_kb = False + message_text = None if tariff_id and await check_tariff_exists(conn, tariff_id): current_tariff = await get_tariff_by_id(conn, tariff_id) @@ -552,7 +578,6 @@ async def process_auto_renew_or_notify( if current_tariff["group_code"] in forbidden_groups: use_change_tariff_kb = True - message_text = KEY_CANNOT_RENEW_CURRENT.format( email=email, hours_left_formatted=expiry_data["hours_left_formatted"], @@ -562,7 +587,6 @@ async def process_auto_renew_or_notify( ) else: use_change_tariff_kb = True - message_text = KEY_CANNOT_RENEW_CURRENT.format( email=email, hours_left_formatted=expiry_data["hours_left_formatted"], @@ -581,17 +605,29 @@ async def process_auto_renew_or_notify( keyboard = build_notification_kb(email) await add_notification(conn, tg_id, notification_id) - text_to_send = message_text if "message_text" in locals() else standard_caption + text_to_send = message_text if message_text is not None else standard_caption await send_notification(bot, tg_id, standard_photo, text_to_send, keyboard) return client_id = key.client_id current_expiry = key.expiry_time duration_days = selected_tariff["duration_days"] - renewal_cost = selected_tariff["price_rub"] - traffic_limit = selected_tariff["traffic_limit"] - device_limit = selected_tariff["device_limit"] - total_gb = traffic_limit if traffic_limit else 0 + + stored_price = getattr(key, "selected_price_rub", None) + renewal_cost = float(stored_price) if stored_price is not None else float(selected_tariff["price_rub"]) + + selected_device_limit = getattr(key, "selected_device_limit", None) + selected_traffic_limit = getattr(key, "selected_traffic_limit", None) + selected_traffic_gb = int(selected_traffic_limit) if selected_traffic_limit is not None else None + + device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key( + session=conn, + tariff_id=int(selected_tariff["id"]), + selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None, + selected_traffic_gb=selected_traffic_gb, + ) + traffic_limit_gb_effective = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0 + total_gb = traffic_limit_gb_effective new_expiry_time = ( current_expiry @@ -600,7 +636,6 @@ async def process_auto_renew_or_notify( ) + duration_days * 24 * 60 * 60 * 1000 formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M") - formatted_expiry_date = formatted_expiry_date.replace( datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"), get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)), @@ -619,7 +654,7 @@ async def process_auto_renew_or_notify( client_id=client_id, new_expiry_time=int(new_expiry_time), total_gb=total_gb, - hwid_device_limit=device_limit, + hwid_device_limit=device_limit_effective, session=conn, target_subgroup=key_subgroup, old_subgroup=key_subgroup, @@ -633,10 +668,8 @@ async def process_auto_renew_or_notify( renewed_message = get_renewal_message( tariff_name=selected_tariff["name"], - traffic_limit=selected_tariff.get("traffic_limit") - if selected_tariff.get("traffic_limit") is not None - else 0, - device_limit=selected_tariff.get("device_limit") if selected_tariff.get("device_limit") is not None else 0, + traffic_limit=traffic_limit_gb_effective, + device_limit=device_limit_effective, expiry_date=formatted_expiry_date, subgroup_title=selected_tariff.get("subgroup_title", ""), ) diff --git a/handlers/notifications/notify_utils.py b/handlers/notifications/notify_utils.py index 69f6b4b7..d966612d 100644 --- a/handlers/notifications/notify_utils.py +++ b/handlers/notifications/notify_utils.py @@ -15,11 +15,15 @@ from aiogram.exceptions import ( from aiogram.types import BufferedInputFile, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from database import create_blocked_user, get_tariff_by_id -from handlers.utils import format_hours +from database import create_blocked_user +from handlers.tariffs.tariff_display import get_key_tariff_display +from handlers.utils import format_hours, format_minutes, get_russian_month from logger import logger +moscow_tz = pytz.timezone("Europe/Moscow") + + async def send_messages_with_limit( bot: Bot, messages: list[dict], @@ -27,10 +31,6 @@ async def send_messages_with_limit( source_file: str = None, messages_per_second: int = 25, ): - """ - Отправляет сообщения с ограничением по количеству сообщений в секунду. - Возвращает список результатов отправки (True для успеха, False для ошибки). - """ batch_size = messages_per_second results = [] @@ -65,9 +65,6 @@ async def send_messages_with_limit( async def try_add_blocked_user(tg_id: int, session: AsyncSession, source_file: str | None): - """ - Добавляет пользователя в список заблокировавших бота, если требуется. - """ if source_file == "special_notifications" and session: try: await create_blocked_user(session, tg_id) @@ -108,9 +105,6 @@ async def send_notification( caption: str, keyboard: InlineKeyboardMarkup | None = None, ) -> bool: - """ - Отправляет уведомление пользователю. - """ if image_filename is None: return await _send_text_notification(bot, tg_id, caption, keyboard) @@ -131,7 +125,6 @@ async def _send_photo_notification( caption: str, keyboard: InlineKeyboardMarkup | None = None, ) -> bool: - """Отправляет уведомление с изображением.""" try: async with aiofiles.open(photo_path, "rb") as image_file: image_data = await image_file.read() @@ -152,7 +145,6 @@ async def _send_text_notification( caption: str, keyboard: InlineKeyboardMarkup | None = None, ) -> bool: - """Отправляет текстовое уведомление.""" try: await bot.send_message(tg_id, caption, reply_markup=keyboard) return True @@ -164,35 +156,73 @@ async def _send_text_notification( async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int) -> dict: - moscow_tz = pytz.timezone("Europe/Moscow") + """Готовит данные об истечении подписки для уведомлений.""" + if isinstance(key, dict): + expiry_timestamp = key.get("expiry_time") + email = key.get("email") or "" + record = dict(key) + else: + expiry_timestamp = getattr(key, "expiry_time", None) + email = getattr(key, "email", "") or "" + record = { + "tariff_id": getattr(key, "tariff_id", None), + "server_id": getattr(key, "server_id", None), + "client_id": getattr(key, "client_id", None), + "selected_device_limit": getattr(key, "selected_device_limit", None), + "selected_traffic_limit": getattr(key, "selected_traffic_limit", None), + } - expiry_timestamp = key.expiry_time - hours_left = int((expiry_timestamp - current_time) / (1000 * 3600)) - hours_left_formatted = ( - f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!" - ) + if not expiry_timestamp: + return { + "hours_left_formatted": "", + "formatted_expiry_date": "", + "tariff_name": "—", + "tariff_details": "", + } + + delta_ms = max(0, expiry_timestamp - current_time) + total_minutes = delta_ms // (60 * 1000) + hours_left = total_minutes // 60 + minutes_left = total_minutes % 60 + + if hours_left > 0 or minutes_left > 0: + parts = [] + if hours_left > 0: + parts.append(format_hours(hours_left)) + if minutes_left > 0: + parts.append(format_minutes(minutes_left)) + hours_left_formatted = f"⏳ Осталось времени: {' '.join(parts)}" + else: + hours_left_formatted = "⏳ Последний день подписки!" expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz) - formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)") + month_name = get_russian_month(expiry_datetime) + formatted_expiry_date = expiry_datetime.strftime(f"%d {month_name} %Y, %H:%M (МСК)") tariff_name = "—" - tariff_details = "" + subgroup_title = "" + traffic_limit_gb = 0 + device_limit = 0 - if getattr(key, "tariff_id", None): - tariff = await get_tariff_by_id(session, key.tariff_id) - if tariff: - tariff_name = tariff.get("name") or "—" - traffic_limit = tariff.get("traffic_limit") or 0 - device_limit = tariff.get("device_limit") or 0 - subgroup_title = tariff.get("subgroup_title", "") - traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ" - devices_text = "безлимит" if device_limit <= 0 else str(device_limit) - lines = [] - if subgroup_title: - lines.append(subgroup_title) - lines.append(f"Трафик: {traffic_text}") - lines.append(f"Устройств: {devices_text}") - tariff_details = "\n" + "\n".join(lines) + try: + name, subgroup_title, traffic_limit_gb, device_limit, _ = await get_key_tariff_display( + session=session, + key_record=record, + ) + if name: + tariff_name = name + except Exception as error: + logger.warning(f"[NOTIFY] Ошибка при получении тарифных лимитов для {email}: {error}") + + traffic_text = "безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ" + devices_text = "безлимит" if device_limit == 0 else str(device_limit) + + lines = [] + if subgroup_title: + lines.append(subgroup_title) + lines.append(f"Трафик: {traffic_text}") + lines.append(f"Устройств: {devices_text}") + tariff_details = "\n" + "\n".join(lines) if lines else "" return { "hours_left_formatted": hours_left_formatted, diff --git a/handlers/payments/constants.py b/handlers/payments/constants.py new file mode 100644 index 00000000..ea30cdd4 --- /dev/null +++ b/handlers/payments/constants.py @@ -0,0 +1,6 @@ +ALLOWED_TEMP_PAYMENT_STATES = { + "waiting_for_payment", + "waiting_for_renewal_payment", + "waiting_for_gift_payment", + "waiting_for_addons_payment", +} \ No newline at end of file diff --git a/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so index b4f5ad09..1cc97c26 100644 Binary files a/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/cryptobot/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so index fdb9eec3..0d14d307 100644 Binary files a/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/cryptobot/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so index d8da69e2..72287ff7 100644 Binary files a/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/cryptobot/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/heleket/handlers.py b/handlers/payments/heleket/handlers.py index 665248cd..8e48237f 100644 --- a/handlers/payments/heleket/handlers.py +++ b/handlers/payments/heleket/handlers.py @@ -11,6 +11,7 @@ from handlers.payments.currency_rates import format_for_user from handlers.texts import DEFAULT_PAYMENT_MESSAGE from handlers.utils import edit_or_send_message from logger import logger +from ..constants import ALLOWED_TEMP_PAYMENT_STATES from .service import ( HELEKET_PAYMENT_METHODS, @@ -52,12 +53,7 @@ async def handle_custom_amount_input_heleket( tg_id = from_user.id temp_data = await get_temporary_data(session, tg_id) - valid_states = [ - "waiting_for_payment", - "waiting_for_renewal_payment", - "waiting_for_gift_payment", - ] - if not temp_data or temp_data["state"] not in valid_states: + if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES: await edit_or_send_message( target_message=message, text="❌ Не удалось получить данные для оплаты.", diff --git a/handlers/payments/kassai/handlers.py b/handlers/payments/kassai/handlers.py index adf79274..117d934c 100644 --- a/handlers/payments/kassai/handlers.py +++ b/handlers/payments/kassai/handlers.py @@ -11,6 +11,7 @@ from handlers.payments.currency_rates import format_for_user from handlers.texts import DEFAULT_PAYMENT_MESSAGE from handlers.utils import edit_or_send_message from logger import logger +from ..constants import ALLOWED_TEMP_PAYMENT_STATES from .service import ( KASSAI_PAYMENT_METHODS, @@ -72,12 +73,7 @@ async def _handle_custom_amount_input_kassai( tg_id = from_user.id temp_data = await get_temporary_data(session, tg_id) - valid_states = [ - "waiting_for_payment", - "waiting_for_renewal_payment", - "waiting_for_gift_payment", - ] - if not temp_data or temp_data["state"] not in valid_states: + if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES: await edit_or_send_message( target_message=message, text="❌ Не удалось получить данные для оплаты.", diff --git a/handlers/payments/robokassa/handlers.py b/handlers/payments/robokassa/handlers.py index abbd1dc0..0e304587 100644 --- a/handlers/payments/robokassa/handlers.py +++ b/handlers/payments/robokassa/handlers.py @@ -15,11 +15,11 @@ from handlers.payments.keyboards import ( pay_keyboard as build_pay_keyboard, payment_options_for_user, ) - from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM from handlers.payments.currency_rates import format_for_user from handlers.utils import edit_or_send_message from logger import logger +from ..constants import ALLOWED_TEMP_PAYMENT_STATES from .service import create_and_store_robokassa_payment @@ -117,7 +117,7 @@ async def handle_custom_amount_input( from_user = event.from_user tg_id = from_user.id temp_data = await get_temporary_data(session, tg_id) - if not temp_data or temp_data["state"] not in ["waiting_for_payment", "waiting_for_renewal_payment", "waiting_for_gift_payment"]: + if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES: await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.") return amount = int(temp_data["data"].get("required_amount", 0)) @@ -153,8 +153,7 @@ async def handle_custom_amount_input( await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup) except Exception as e: - from logger import logger as _lg - _lg.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}") + logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}") await edit_or_send_message( target_message=message, text="Произошла ошибка при создании платежа. Попробуйте позже.", diff --git a/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so index 1c8ee17b..f44fef13 100644 Binary files a/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/stars/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so b/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so index a97093ae..d02e22ec 100644 Binary files a/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/stars/keyboards.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so index 290ae4ba..c399d0c7 100644 Binary files a/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so index 9b88cd65..0e3bd492 100644 Binary files a/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so index 35ee927e..359e29f0 100644 Binary files a/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so b/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so index c85e8701..48203691 100644 Binary files a/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so index c7a095f7..6a6719ad 100644 Binary files a/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yookassa/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so index fabc3a53..6e0b1ffb 100644 Binary files a/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yookassa/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so index 99a4aa4e..ca43d6f4 100644 Binary files a/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yookassa/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so index 03e00743..f664d022 100644 Binary files a/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yoomoney/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so index ffb2cb30..c6a27872 100644 Binary files a/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yoomoney/service.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so b/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so index b1ffed20..893d940b 100644 Binary files a/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/yoomoney/webhook.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/start.py b/handlers/start.py index c81cc552..d8b02139 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -44,7 +44,7 @@ from handlers.buttons import ( from handlers.captcha import generate_captcha from handlers.coupons import activate_coupon from handlers.instructions.instructions import send_instructions -from handlers.keys.key_mode.key_create import confirm_create_new_key +from handlers.keys.key_create import confirm_create_new_key from handlers.keys.key_view import process_callback_or_message_view_keys from handlers.payments.gift import handle_gift_link from handlers.profile import process_callback_view_profile diff --git a/handlers/tariffs/__init__.py b/handlers/tariffs/__init__.py new file mode 100644 index 00000000..1f9d30ef --- /dev/null +++ b/handlers/tariffs/__init__.py @@ -0,0 +1,10 @@ +__all__ = ("router",) + +from aiogram import Router + +from .addons.key_addons import router as addons_router + + +router = Router(name="keys_main_router") + +router.include_routers(addons_router) diff --git a/handlers/tariffs/addons/key_addons.py b/handlers/tariffs/addons/key_addons.py new file mode 100644 index 00000000..552068cf --- /dev/null +++ b/handlers/tariffs/addons/key_addons.py @@ -0,0 +1,35 @@ +from aiogram import Router +from aiogram.types import CallbackQuery + +from core.settings.tariffs_config import TARIFFS_CONFIG + +from . import key_addons_main, key_addons_pack +from .utils import ( + UNLIMITED_DEVICES_LABEL, + UNLIMITED_TRAFFIC_LABEL, + KeyAddonConfigState, + build_addons_screen_text, + format_devices_label, + format_traffic_label, + is_not_downgrade, +) + + +def is_pack_mode_enabled() -> bool: + return bool(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE")) + + +def _main_mode_filter(callback: CallbackQuery, *args, **kwargs) -> bool: + return not is_pack_mode_enabled() + + +def _pack_mode_filter(callback: CallbackQuery, *args, **kwargs) -> bool: + return is_pack_mode_enabled() + + +key_addons_main.router.callback_query.filter(_main_mode_filter) +key_addons_pack.router.callback_query.filter(_pack_mode_filter) + +router = Router() +router.include_router(key_addons_main.router) +router.include_router(key_addons_pack.router) diff --git a/handlers/tariffs/addons/key_addons_main.py b/handlers/tariffs/addons/key_addons_main.py new file mode 100644 index 00000000..e29de73a --- /dev/null +++ b/handlers/tariffs/addons/key_addons_main.py @@ -0,0 +1,940 @@ +from math import ceil + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from sqlalchemy.ext.asyncio import AsyncSession + +from config import USE_NEW_PAYMENT_FLOW +from core.settings.tariffs_config import TARIFFS_CONFIG, normalize_tariff_config +from database import get_balance, get_key_details, get_tariff_by_id, save_key_config_with_mode, update_balance +from handlers.buttons import ( + BACK, + CONFIRM_ADDON_BUTTON_TEXT, + DOWNGRADE_ADDON_BUTTON_TEXT, + DOWNGRADE_CONFIRM_BUTTON_TEXT, + PAYMENT, +) +from handlers.keys.key_view import render_key_info +from handlers.payments.currency_rates import format_for_user +from handlers.payments.fast_payment_flow import try_fast_payment_flow +from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key +from handlers.texts import ( + ADDONS_APPLIED_TEXT, + DOWNGRADE_INLINE_WARNING_TEXT, + DOWNGRADE_SAVED_TEXT, + DOWNGRADE_WARNING_TEXT, + INSUFFICIENT_FUNDS_RENEWAL_MSG, + NO_EXTRA_PAYMENT_TEXT, + UNLIMITED_DEVICES_LABEL, + UNLIMITED_TRAFFIC_LABEL, +) +from handlers.utils import edit_or_send_message +from logger import logger + +from ..buy.key_tariffs import calculate_config_price +from .utils import ( + KeyAddonConfigState, + build_addons_screen_text, + format_devices_label, + format_traffic_label, + is_not_downgrade, +) + + +router = Router() + + +async def render_addons_screen(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + data = await state.get_data() + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + cfg = data.get("addon_tariff_config") or {} + + logger.debug( + f"[ADDONS] render_addons_screen start: tg_id={callback.from_user.id} " + f"email={email} tariff_id={tariff_id} data={data}" + ) + + current_devices = data.get("addon_current_device_limit") + current_traffic_gb = data.get("addon_current_traffic_gb") + original_price = int(data.get("addon_original_price") or 0) + + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + + if not email or not tariff_id: + logger.warning(f"[ADDONS] Нет email или tariff_id в состоянии: {data}") + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] Тариф {tariff_id} не найден в render_addons_screen") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + tariff_name = tariff.get("name") or "подписка" + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + device_int_options: list[int] = [] + for value in device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + has_device_option = bool(device_int_options) + has_device_choice = len(device_int_options) > 1 + + has_traffic_option = bool(traffic_int_options) + has_traffic_choice = len(traffic_int_options) > 1 + + if selected_devices is None and has_device_option: + if current_devices is not None and int(current_devices) in device_int_options: + selected_devices = int(current_devices) + elif device_int_options: + selected_devices = device_int_options[0] + + if selected_traffic_gb is None and has_traffic_option: + if current_traffic_gb is not None and int(current_traffic_gb) in traffic_int_options: + selected_traffic_gb = int(current_traffic_gb) + elif traffic_int_options: + selected_traffic_gb = traffic_int_options[0] + + logger.debug( + "[ADDONS] Limits before price: " + f"current_devices={current_devices} current_traffic_gb={current_traffic_gb} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " + f"original_price={original_price}" + ) + + await state.update_data( + addon_selected_device_limit=selected_devices, + addon_selected_traffic_gb=selected_traffic_gb, + ) + + current_devices_for_price = int(current_devices) if current_devices is not None and has_device_option else None + current_traffic_for_price = ( + int(current_traffic_gb) if current_traffic_gb is not None and has_traffic_option else None + ) + base_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_for_price, + ) + + total_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_option + else None, + ) + extra_price = max(0, total_price - base_price_for_current) + + logger.debug( + "[ADDONS] Calculated prices: " + f"base_price_for_current={base_price_for_current} total_price={total_price} extra_price={extra_price} " + f"has_device_option={has_device_option} has_traffic_option={has_traffic_option}" + ) + + tg_id = callback.from_user.id + language_code = getattr(callback.from_user, "language_code", None) + + total_price_text = await format_for_user(session, tg_id, float(total_price), language_code) + extra_price_text = await format_for_user(session, tg_id, float(extra_price), language_code) + + current_devices_label = format_devices_label(current_devices) + current_traffic_label = format_traffic_label(current_traffic_gb) + new_devices_label = format_devices_label(selected_devices) + new_traffic_label = format_traffic_label(selected_traffic_gb) + + downgrade_warning = None + devices_downgrade = False + traffic_downgrade = False + allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True)) + + if allow_downgrade: + if has_device_choice and current_devices is not None and selected_devices is not None: + devices_downgrade = not is_not_downgrade(current_devices, selected_devices) + if has_traffic_choice and current_traffic_gb is not None and selected_traffic_gb is not None: + traffic_downgrade = not is_not_downgrade(current_traffic_gb, selected_traffic_gb) + if devices_downgrade or traffic_downgrade: + new_limits_parts = [] + if has_device_choice: + new_limits_parts.append(new_devices_label) + if has_traffic_choice: + new_limits_parts.append(new_traffic_label) + new_limits_desc = ", ".join(new_limits_parts) if new_limits_parts else "выбранные параметры" + downgrade_warning = DOWNGRADE_INLINE_WARNING_TEXT.format( + total_price_text=total_price_text, + new_limits_desc=new_limits_desc, + ) + + logger.debug( + "[ADDONS] Downgrade flags: " + f"devices_downgrade={devices_downgrade} traffic_downgrade={traffic_downgrade} " + f"ALLOW_DOWNGRADE={allow_downgrade}" + ) + + text = build_addons_screen_text( + tariff_name=tariff_name, + current_devices_label=current_devices_label, + current_traffic_label=current_traffic_label, + new_devices_label=new_devices_label, + new_traffic_label=new_traffic_label, + has_device_choice=has_device_choice, + has_traffic_choice=has_traffic_choice, + total_price_text=total_price_text, + extra_price_text=extra_price_text, + downgrade_warning=downgrade_warning, + ) + + builder = InlineKeyboardBuilder() + device_buttons = [] + traffic_buttons = [] + + if has_device_choice: + for value in device_int_options: + if not allow_downgrade and not is_not_downgrade(current_devices, value): + continue + is_selected = selected_devices is not None and int(value) == int(selected_devices) + mark = " ✅" if is_selected else "" + if value == 0: + caption = f"{UNLIMITED_DEVICES_LABEL.capitalize()}{mark}" + else: + caption = f"{value} устройств{mark}" + device_buttons.append( + InlineKeyboardButton( + text=caption, + callback_data=f"key_addons_devices|{email}|{value}", + ) + ) + + if has_traffic_choice: + for value in traffic_int_options: + if not allow_downgrade and not is_not_downgrade(current_traffic_gb, value): + continue + is_selected = selected_traffic_gb is not None and int(selected_traffic_gb) == int(value) + mark = " ✅" if is_selected else "" + if value == 0: + caption = f"{UNLIMITED_TRAFFIC_LABEL.capitalize()}{mark}" + else: + caption = f"{value} ГБ{mark}" + traffic_buttons.append( + InlineKeyboardButton( + text=caption, + callback_data=f"key_addons_traffic|{email}|{value}", + ) + ) + + logger.debug( + f"[ADDONS] Buttons: devices={[b.text for b in device_buttons]} traffic={[b.text for b in traffic_buttons]}" + ) + + if device_buttons and traffic_buttons: + max_len = max(len(device_buttons), len(traffic_buttons)) + for i in range(max_len): + row = [] + if i < len(device_buttons): + row.append(device_buttons[i]) + if i < len(traffic_buttons): + row.append(traffic_buttons[i]) + builder.row(*row) + elif device_buttons: + for button in device_buttons: + builder.row(button) + elif traffic_buttons: + for button in traffic_buttons: + builder.row(button) + + if allow_downgrade and (devices_downgrade or traffic_downgrade): + builder.row( + InlineKeyboardButton( + text=DOWNGRADE_ADDON_BUTTON_TEXT, + callback_data="key_addons_downgrade", + ) + ) + else: + builder.row( + InlineKeyboardButton( + text=CONFIRM_ADDON_BUTTON_TEXT.format(amount=extra_price_text), + callback_data="key_addons_confirm", + ) + ) + + builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}")) + + await edit_or_send_message( + target_message=callback.message, + text=text, + reply_markup=builder.as_markup(), + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("key_addons|")) +async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + email = callback.data.split("|")[1] + logger.debug(f"[ADDONS] start_key_addons: tg_id={callback.from_user.id} email={email}") + + record = await get_key_details(session, email) + if not record: + logger.warning(f"[ADDONS] Подписка {email} не найдена") + await callback.message.answer("❌ Подписка не найдена.") + return + + tariff_id = record.get("tariff_id") + if not tariff_id: + logger.warning(f"[ADDONS] Для подписки {email} не назначен тариф") + await callback.message.answer("❌ Для этой подписки тариф не назначен, расширение недоступно.") + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] Тариф {tariff_id} не найден для email={email}") + await callback.message.answer("❌ Тариф не найден.") + return + + if not tariff.get("configurable"): + logger.info(f"[ADDONS] Тариф {tariff_id} не конфигурируемый, расширение недоступно") + await callback.message.answer("❌ Для этого тарифа расширение через конфигуратор недоступно.") + return + + cfg = normalize_tariff_config(tariff) + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + device_int_options: list[int] = [] + for value in raw_device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in raw_traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + base_device_limit = cfg.get("base_device_limit") + if base_device_limit is None: + base_device_limit = tariff.get("device_limit") + if base_device_limit is not None: + try: + base_device_int = int(base_device_limit) + if base_device_int not in device_int_options: + raw_device_options.append(base_device_int) + device_int_options.append(base_device_int) + except (TypeError, ValueError): + pass + + device_overrides_cfg = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {} + if "0" in device_overrides_cfg and 0 not in device_int_options: + raw_device_options.append(0) + device_int_options.append(0) + + base_traffic_gb = cfg.get("base_traffic_gb") + if base_traffic_gb is None: + raw_limit = tariff.get("traffic_limit") + if raw_limit: + raw_limit = int(raw_limit) + if raw_limit >= GB: + base_traffic_gb = int(raw_limit / GB) + else: + base_traffic_gb = raw_limit + if base_traffic_gb is not None: + try: + base_traffic_int = int(base_traffic_gb) + if base_traffic_int not in traffic_int_options: + raw_traffic_options.append(base_traffic_int) + traffic_int_options.append(base_traffic_int) + except (TypeError, ValueError): + pass + + traffic_overrides_cfg = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {} + if "0" in traffic_overrides_cfg and 0 not in traffic_int_options: + raw_traffic_options.append(0) + traffic_int_options.append(0) + + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + logger.info( + "[ADDONS] start_key_addons options: " + f"email={email} tariff_id={tariff_id} " + f"device_options={device_options} traffic_options={traffic_options}" + ) + + if not device_options and not traffic_options: + logger.warning(f"[ADDONS] Пустой конфигуратор для тарифа {tariff_id}") + await callback.message.answer("❌ Конфигуратор для этого тарифа не настроен.") + return + + selected_device_limit_db = record.get("selected_device_limit") + selected_traffic_limit_db = record.get("selected_traffic_limit") + original_price_db = record.get("selected_price_rub") + current_device_limit_db = record.get("current_device_limit") + current_traffic_limit_db = record.get("current_traffic_limit") + + base_devices = tariff.get("device_limit") + base_devices = int(base_devices) if base_devices is not None else None + + base_traffic_bytes = tariff.get("traffic_limit") + base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None + + current_devices = ( + int(current_device_limit_db) + if current_device_limit_db is not None + else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices) + ) + current_traffic_gb = ( + int(current_traffic_limit_db) + if current_traffic_limit_db is not None + else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff) + ) + + current_devices_for_price = int(current_devices) if current_devices is not None else None + current_traffic_gb_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None + + config_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_gb_for_price, + ) + + try: + original_price_from_db = int(original_price_db) if original_price_db is not None else 0 + except (TypeError, ValueError): + original_price_from_db = 0 + + original_price = original_price_from_db or int(config_price_for_current) + + logger.debug( + "[ADDONS] start_key_addons state: " + f"email={email} tariff_id={tariff_id} current_devices={current_devices} current_traffic_gb={current_traffic_gb} " + f"config_price_for_current={config_price_for_current} original_price_db={original_price_db} " + f"original_price={original_price}" + ) + + cfg_for_state = dict(cfg) + if device_options: + cfg_for_state["device_options"] = device_options + if traffic_options: + cfg_for_state["traffic_options_gb"] = traffic_options + + await state.update_data( + addon_key_email=email, + addon_tariff_id=int(tariff_id), + addon_tariff_config=cfg_for_state, + addon_current_device_limit=current_devices, + addon_current_traffic_gb=current_traffic_gb, + addon_original_price=original_price, + addon_selected_device_limit=current_devices, + addon_selected_traffic_gb=current_traffic_gb, + ) + await state.set_state(KeyAddonConfigState.configuring) + + await render_addons_screen(callback, state, session) + + +@router.callback_query(F.data.startswith("key_addons_devices|"), KeyAddonConfigState.configuring) +async def handle_addons_devices_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + parts = callback.data.split("|", 2) + if len(parts) < 3: + await callback.answer("Некорректные данные", show_alert=True) + return + + new_devices = int(parts[2]) + data = await state.get_data() + current_devices = data.get("addon_current_device_limit") + selected_devices = data.get("addon_selected_device_limit") + + logger.debug( + "[ADDONS] handle_addons_devices_choice: " + f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} " + f"new_devices={new_devices} current_devices={current_devices} selected_devices={selected_devices}" + ) + + if selected_devices is not None and int(selected_devices) == new_devices: + await callback.answer() + return + + allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True)) + if not allow_downgrade and not is_not_downgrade(current_devices, new_devices): + await callback.answer("Нельзя снижать лимит устройств, только увеличивать.", show_alert=True) + return + + await state.update_data(addon_selected_device_limit=new_devices) + await render_addons_screen(callback, state, session) + + +@router.callback_query(F.data.startswith("key_addons_traffic|"), KeyAddonConfigState.configuring) +async def handle_addons_traffic_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + parts = callback.data.split("|", 2) + if len(parts) < 3: + await callback.answer("Некорректные данные", show_alert=True) + return + + new_traffic_gb = int(parts[2]) + + data = await state.get_data() + current_traffic_gb = data.get("addon_current_traffic_gb") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + + logger.debug( + "[ADDONS] handle_addons_traffic_choice: " + f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} " + f"new_traffic_gb={new_traffic_gb} current_traffic_gb={current_traffic_gb} " + f"selected_traffic_gb={selected_traffic_gb}" + ) + + if selected_traffic_gb is not None and int(selected_traffic_gb) == new_traffic_gb: + await callback.answer() + return + + allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True)) + if not allow_downgrade and not is_not_downgrade(current_traffic_gb, new_traffic_gb): + await callback.answer("Нельзя снижать лимит трафика, только увеличивать.", show_alert=True) + return + + await state.update_data(addon_selected_traffic_gb=new_traffic_gb) + await render_addons_screen(callback, state, session) + + +@router.callback_query(F.data == "key_addons_downgrade", KeyAddonConfigState.configuring) +async def handle_addons_downgrade(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + tg_id = callback.from_user.id + data = await state.get_data() + + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + current_devices = data.get("addon_current_device_limit") + current_traffic_gb = data.get("addon_current_traffic_gb") + + logger.info( + "[ADDONS] handle_addons_downgrade: " + f"tg_id={tg_id} email={email} tariff_id={tariff_id} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " + f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}" + ) + + if not email or not tariff_id: + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] Тариф {tariff_id} не найден в handle_addons_downgrade") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + cfg = data.get("addon_tariff_config") or {} + device_options = cfg.get("device_options") or [] + traffic_options = cfg.get("traffic_options_gb") or [] + has_device_option = bool(device_options) + has_traffic_option = bool(traffic_options) + has_device_choice = len(device_options) > 1 + has_traffic_choice = len(traffic_options) > 1 + + total_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_option + else None, + ) + + logger.debug( + "[ADDONS] Downgrade price: " + f"total_price={total_price} has_device_option={has_device_option} has_traffic_option={has_traffic_option}" + ) + + language_code = getattr(callback.from_user, "language_code", None) + total_price_text = await format_for_user(session, tg_id, float(total_price), language_code) + + current_devices_label = format_devices_label(current_devices) + current_traffic_label = format_traffic_label(current_traffic_gb) + new_devices_label = format_devices_label(selected_devices) + new_traffic_label = format_traffic_label(selected_traffic_gb) + + text = DOWNGRADE_WARNING_TEXT.format( + tariff_name=tariff.get("name") or "подписка", + current_devices_label=current_devices_label if has_device_choice else "по умолчанию", + current_traffic_label=current_traffic_label if has_traffic_choice else "по умолчанию", + new_devices_label=new_devices_label if has_device_choice else "по умолчанию", + new_traffic_label=new_traffic_label if has_traffic_choice else "по умолчанию", + total_price_text=total_price_text, + ) + + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=DOWNGRADE_CONFIRM_BUTTON_TEXT, + callback_data="key_addons_downgrade_apply", + ) + ) + builder.row(InlineKeyboardButton(text=BACK, callback_data=f"key_addons|{email}")) + + await edit_or_send_message( + target_message=callback.message, + text=text, + reply_markup=builder.as_markup(), + ) + await callback.answer() + + +@router.callback_query(F.data == "key_addons_downgrade_apply", KeyAddonConfigState.configuring) +async def handle_addons_downgrade_apply(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + data = await state.get_data() + + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + + logger.info( + "[ADDONS] handle_addons_downgrade_apply: " + f"tg_id={callback.from_user.id} email={email} tariff_id={tariff_id} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb}" + ) + + if not email or not tariff_id: + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] Тариф {tariff_id} не найден в handle_addons_downgrade_apply") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + cfg = data.get("addon_tariff_config") or {} + device_options = cfg.get("device_options") or [] + traffic_options = cfg.get("traffic_options_gb") or [] + has_device_option = bool(device_options) + has_traffic_option = bool(traffic_options) + has_device_choice = len(device_options) > 1 + has_traffic_choice = len(traffic_options) > 1 + + total_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_option + else None, + ) + + logger.debug( + "[ADDONS] Downgrade apply price: " + f"total_price={total_price} has_device_choice={has_device_choice} has_traffic_choice={has_traffic_choice}" + ) + + try: + await save_key_config_with_mode( + session=session, + email=email, + selected_devices=selected_devices, + selected_traffic_gb=selected_traffic_gb, + total_price=int(total_price), + has_device_choice=has_device_choice, + has_traffic_choice=has_traffic_choice, + config_mode="downgrade", + ) + await session.commit() + except Exception as error: + logger.error(f"[ADDONS] Ошибка при сохранении будущих условий для {email}: {error}") + await callback.message.answer("❌ Ошибка при сохранении новых условий. Попробуйте позже.") + await state.clear() + return + + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(DOWNGRADE_SAVED_TEXT, show_alert=True) + + +@router.callback_query(F.data == "key_addons_confirm", KeyAddonConfigState.configuring) +async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + from handlers.keys.operations import renew_key_in_cluster + + tg_id = callback.from_user.id + data = await state.get_data() + + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + original_price = int(data.get("addon_original_price") or 0) + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + current_devices = data.get("addon_current_device_limit") + current_traffic_gb = data.get("addon_current_traffic_gb") + + logger.info( + "[ADDONS] handle_addons_confirm: " + f"tg_id={tg_id} email={email} tariff_id={tariff_id} original_price={original_price} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " + f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}" + ) + + if not email or not tariff_id: + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return + + record = await get_key_details(session, email) + if not record: + logger.warning(f"[ADDONS] Подписка {email} не найдена в handle_addons_confirm") + await callback.message.answer("❌ Подписка не найдена.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] Тариф {tariff_id} не найден в handle_addons_confirm") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + cfg = data.get("addon_tariff_config") or {} + device_options = cfg.get("device_options") or [] + traffic_options = cfg.get("traffic_options_gb") or [] + has_device_option = bool(device_options) + has_traffic_option = bool(traffic_options) + has_device_choice = len(device_options) > 1 + has_traffic_choice = len(traffic_options) > 1 + + current_devices_for_price = int(current_devices) if current_devices is not None and has_device_option else None + current_traffic_for_price = ( + int(current_traffic_gb) if current_traffic_gb is not None and has_traffic_option else None + ) + base_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_for_price, + ) + + total_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_option + else None, + ) + extra_price = max(0, total_price - base_price_for_current) + + logger.debug( + "[ADDONS] Confirm prices: " + f"base_price_for_current={base_price_for_current} total_price={total_price} " + f"original_price={original_price} extra_price={extra_price} " + f"has_device_option={has_device_option} has_traffic_option={has_traffic_option}" + ) + + allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True)) + devices_downgrade = ( + allow_downgrade + and has_device_choice + and current_devices is not None + and selected_devices is not None + and not is_not_downgrade(current_devices, selected_devices) + ) + traffic_downgrade = ( + allow_downgrade + and has_traffic_choice + and current_traffic_gb is not None + and selected_traffic_gb is not None + and not is_not_downgrade(current_traffic_gb, selected_traffic_gb) + ) + + logger.debug( + f"[ADDONS] Confirm downgrade flags: devices_downgrade={devices_downgrade} traffic_downgrade={traffic_downgrade}" + ) + + if devices_downgrade or traffic_downgrade: + await handle_addons_downgrade(callback, state, session) + return + + if extra_price <= 0: + logger.info(f"[ADDONS] extra_price <= 0, доплата не требуется, email={email}") + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(NO_EXTRA_PAYMENT_TEXT, show_alert=True) + return + + balance = await get_balance(session, tg_id) + logger.debug(f"[ADDONS] Balance check: tg_id={tg_id} balance={balance} extra_price={extra_price}") + + if balance < extra_price: + required_amount = ceil(extra_price - balance) + language_code = getattr(callback.from_user, "language_code", None) + required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) + + logger.info( + "[ADDONS] Недостаточно средств: " + f"balance={balance} extra_price={extra_price} required_amount={required_amount} " + f"tg_id={tg_id} USE_NEW_PAYMENT_FLOW={USE_NEW_PAYMENT_FLOW}" + ) + + if USE_NEW_PAYMENT_FLOW: + handled = await try_fast_payment_flow( + callback, + session, + state, + tg_id=tg_id, + temp_key="waiting_for_addons_payment", + temp_payload={ + "email": email, + "tariff_id": int(tariff_id), + "original_price": original_price, + "selected_device_limit": selected_devices, + "selected_traffic_gb": selected_traffic_gb, + "current_device_limit": current_devices, + "current_traffic_gb": current_traffic_gb, + "required_amount": required_amount, + }, + required_amount=required_amount, + ) + logger.debug(f"[ADDONS] try_fast_payment_flow handled={handled} tg_id={tg_id} email={email}") + if handled: + return + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) + await edit_or_send_message( + target_message=callback.message, + text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text), + reply_markup=builder.as_markup(), + ) + return + + try: + expiry_time = record["expiry_time"] + client_id = record["client_id"] + server_id = record["server_id"] + + selected_traffic_gb_for_effective = ( + int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_option else 0 + ) + + current_subgroup = None + try: + current_tariff_id = record.get("tariff_id") + if current_tariff_id: + current_tariff = await get_tariff_by_id(session, int(current_tariff_id)) + if current_tariff: + current_subgroup = current_tariff.get("subgroup_title") + except Exception as error: + logger.warning(f"[ADDONS] Не удалось определить текущую подгруппу: {error}") + + target_subgroup = tariff.get("subgroup_title") + old_subgroup = current_subgroup + + device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff_id), + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None, + selected_traffic_gb=selected_traffic_gb_for_effective, + ) + traffic_limit_gb_effective = ( + int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0 + ) + total_gb = traffic_limit_gb_effective + hwid_device_limit_to_set = device_limit_effective_new + + logger.debug( + "[ADDONS] renew_key_in_cluster params: " + f"server_id={server_id} email={email} client_id={client_id} total_gb={total_gb} " + f"hwid_device_limit_to_set={hwid_device_limit_to_set} target_subgroup={target_subgroup} " + f"old_subgroup={old_subgroup}" + ) + + await renew_key_in_cluster( + cluster_id=server_id, + email=email, + client_id=client_id, + new_expiry_time=expiry_time, + total_gb=total_gb, + session=session, + hwid_device_limit=hwid_device_limit_to_set, + reset_traffic=False, + target_subgroup=target_subgroup, + old_subgroup=old_subgroup, + plan=int(tariff_id), + ) + + await save_key_config_with_mode( + session=session, + email=email, + selected_devices=selected_devices, + selected_traffic_gb=selected_traffic_gb, + total_price=int(total_price), + has_device_choice=has_device_choice, + has_traffic_choice=has_traffic_choice, + config_mode="addon", + ) + + await update_balance(session, tg_id, -extra_price) + await session.commit() + + logger.info( + "[ADDONS] Успешное применение расширения: " + f"tg_id={tg_id} email={email} total_price={total_price} extra_price={extra_price}" + ) + + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(ADDONS_APPLIED_TEXT, show_alert=True) + + except Exception as error: + logger.error(f"[ADDONS] Ошибка при применении расширения подписки для {email}: {error}") + await callback.message.answer("❌ Ошибка при обновлении подписки. Попробуйте позже.") + await state.clear() diff --git a/handlers/tariffs/addons/key_addons_pack.py b/handlers/tariffs/addons/key_addons_pack.py new file mode 100644 index 00000000..8db38cef --- /dev/null +++ b/handlers/tariffs/addons/key_addons_pack.py @@ -0,0 +1,814 @@ +from math import ceil + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from sqlalchemy.ext.asyncio import AsyncSession + +from config import USE_NEW_PAYMENT_FLOW +from core.settings.tariffs_config import TARIFFS_CONFIG, normalize_tariff_config +from database import ( + get_balance, + get_key_details, + get_tariff_by_id, + save_key_config_with_mode, + update_balance, +) +from handlers.buttons import BACK, CONFIRM_ADDON_BUTTON_TEXT, PAYMENT +from handlers.keys.key_view import render_key_info +from handlers.payments.currency_rates import format_for_user +from handlers.payments.fast_payment_flow import try_fast_payment_flow +from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key +from handlers.texts import ( + ADDONS_NO_EXTRA_PAYMENT_TEXT, + ADDONS_PACK_SUCCESS_TEXT, + INSUFFICIENT_FUNDS_RENEWAL_MSG, + UNLIMITED_DEVICES_LABEL, + UNLIMITED_TRAFFIC_LABEL, +) +from handlers.utils import edit_or_send_message +from logger import logger + +from ..buy.key_tariffs import calculate_config_price +from .utils import ( + KeyAddonConfigState, + build_addons_pack_screen_text, + format_devices_label, + format_traffic_label, +) + + +router = Router() + + +def get_pack_flags() -> tuple[bool, bool, str]: + mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or "" + if not mode: + return False, False, "" + if mode == "traffic": + return False, True, mode + if mode == "devices": + return True, False, mode + if mode == "all": + return True, True, mode + + logger.warning(f"Некорректный KEY_ADDONS_PACK_MODE: {mode!r}") + return False, False, mode + + +async def render_addons_screen(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + data = await state.get_data() + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + cfg = data.get("addon_tariff_config") or {} + + logger.debug( + f"[ADDONS] render_addons_screen PACK_MODE start: tg_id={callback.from_user.id} " + f"email={email} tariff_id={tariff_id} data={data}" + ) + + current_devices = data.get("addon_current_device_limit") + current_traffic_gb = data.get("addon_current_traffic_gb") + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + + if not email or not tariff_id: + logger.warning(f"[ADDONS] PACK_MODE: нет email или tariff_id в состоянии: {data}") + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден в render_addons_screen") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + tariff_name = tariff.get("name") or "подписка" + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + device_int_options: list[int] = [] + for value in device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + pack_devices, pack_traffic, pack_mode = get_pack_flags() + + has_device_option = pack_devices and bool(device_int_options) + has_traffic_option = pack_traffic and bool(traffic_int_options) + + if has_device_option and current_devices is not None and int(current_devices) == 0: + has_device_option = False + selected_devices = None + + if has_traffic_option and current_traffic_gb is not None and int(current_traffic_gb) == 0: + has_traffic_option = False + selected_traffic_gb = None + + if not has_device_option: + selected_devices = None + if not has_traffic_option: + selected_traffic_gb = None + + await state.update_data( + addon_selected_device_limit=selected_devices, + addon_selected_traffic_gb=selected_traffic_gb, + ) + + current_devices_for_price = int(current_devices) if current_devices is not None else None + current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None + + base_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_for_price, + ) + + new_devices_for_price = current_devices_for_price + if has_device_option and selected_devices is not None: + pack_devices_val = int(selected_devices) + if pack_devices_val <= 0 or (new_devices_for_price is not None and new_devices_for_price <= 0): + new_devices_for_price = 0 + else: + new_devices_for_price = (new_devices_for_price or 0) + pack_devices_val + + new_traffic_for_price = current_traffic_for_price + if has_traffic_option and selected_traffic_gb is not None: + pack_traffic_val = int(selected_traffic_gb) + if pack_traffic_val <= 0 or (new_traffic_for_price is not None and new_traffic_for_price <= 0): + new_traffic_for_price = 0 + else: + new_traffic_for_price = (new_traffic_for_price or 0) + pack_traffic_val + + price_with_pack = calculate_config_price( + tariff=tariff, + selected_device_limit=new_devices_for_price, + selected_traffic_gb=new_traffic_for_price, + ) + extra_price = max(0, price_with_pack - base_price_for_current) + + logger.debug( + "[ADDONS] PACK_MODE calculated prices: " + f"base_price_for_current={base_price_for_current} price_with_pack={price_with_pack} extra_price={extra_price} " + f"has_device_option={has_device_option} has_traffic_option={has_traffic_option} pack_mode={pack_mode!r}" + ) + + tg_id = callback.from_user.id + language_code = getattr(callback.from_user, "language_code", None) + + extra_price_text = await format_for_user(session, tg_id, float(extra_price), language_code) + + current_devices_label = format_devices_label(current_devices) + current_traffic_label = format_traffic_label(current_traffic_gb) + + has_device_pack_selected = has_device_option and selected_devices is not None + has_traffic_pack_selected = has_traffic_option and selected_traffic_gb is not None + + if has_device_pack_selected: + selected_devices_label = format_devices_label(selected_devices) + else: + selected_devices_label = None + + if has_traffic_pack_selected: + selected_traffic_label = format_traffic_label(selected_traffic_gb) + else: + selected_traffic_label = None + + if has_device_pack_selected: + current_devices_value = int(current_devices) if current_devices else 0 + selected_devices_value = int(selected_devices) + if current_devices_value <= 0 or selected_devices_value <= 0: + total_devices_value = 0 + else: + total_devices_value = current_devices_value + selected_devices_value + total_devices_label = format_devices_label(total_devices_value) + else: + total_devices_label = None + + if has_traffic_pack_selected: + current_traffic_value = int(current_traffic_gb) if current_traffic_gb else 0 + selected_traffic_value = int(selected_traffic_gb) + if current_traffic_value <= 0 or selected_traffic_value <= 0: + total_after_gb = 0 + else: + total_after_gb = current_traffic_value + selected_traffic_value + total_traffic_label = format_traffic_label(total_after_gb) + else: + total_traffic_label = None + + text = build_addons_pack_screen_text( + tariff_name=tariff_name, + current_devices_label=current_devices_label, + current_traffic_label=current_traffic_label if current_traffic_gb is not None else None, + selected_devices_label=selected_devices_label, + selected_traffic_label=selected_traffic_label, + total_devices_label=total_devices_label, + total_traffic_label=total_traffic_label, + extra_price_text=extra_price_text, + has_device_option=has_device_option, + has_traffic_option=has_traffic_option, + ) + + builder = InlineKeyboardBuilder() + device_buttons = [] + traffic_buttons = [] + + if has_device_option: + for value in device_int_options: + is_selected = selected_devices is not None and int(value) == int(selected_devices) + mark = " ✅" if is_selected else "" + if value == 0: + caption = f"{UNLIMITED_DEVICES_LABEL.capitalize()}{mark}" + else: + caption = f"{value} устройств{mark}" + device_buttons.append( + InlineKeyboardButton( + text=caption, + callback_data=f"key_addons_devices|{email}|{value}", + ) + ) + + if has_traffic_option: + for value in traffic_int_options: + is_selected = selected_traffic_gb is not None and int(selected_traffic_gb) == int(value) + mark = " ✅" if is_selected else "" + if value == 0: + caption = f"{UNLIMITED_TRAFFIC_LABEL.capitalize()}{mark}" + else: + caption = f"{value} ГБ{mark}" + traffic_buttons.append( + InlineKeyboardButton( + text=caption, + callback_data=f"key_addons_traffic|{email}|{value}", + ) + ) + + logger.debug( + "[ADDONS] PACK_MODE buttons: " + f"devices={[b.text for b in device_buttons]} " + f"traffic={[b.text for b in traffic_buttons]}" + ) + + if device_buttons and traffic_buttons: + max_len = max(len(device_buttons), len(traffic_buttons)) + for i in range(max_len): + row = [] + if i < len(device_buttons): + row.append(device_buttons[i]) + if i < len(traffic_buttons): + row.append(traffic_buttons[i]) + builder.row(*row) + elif device_buttons: + for button in device_buttons: + builder.row(button) + elif traffic_buttons: + for button in traffic_buttons: + builder.row(button) + + builder.row( + InlineKeyboardButton( + text=CONFIRM_ADDON_BUTTON_TEXT.format(amount=extra_price_text), + callback_data="key_addons_confirm", + ) + ) + builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}")) + + await edit_or_send_message( + target_message=callback.message, + text=text, + reply_markup=builder.as_markup(), + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("key_addons|")) +async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + email = callback.data.split("|")[1] + logger.debug(f"[ADDONS] PACK_MODE start_key_addons: tg_id={callback.from_user.id} email={email}") + + record = await get_key_details(session, email) + if not record: + logger.warning(f"[ADDONS] PACK_MODE: подписка {email} не найдена") + await callback.message.answer("❌ Подписка не найдена.") + return + + tariff_id = record.get("tariff_id") + if not tariff_id: + logger.warning(f"[ADDONS] PACK_MODE: для подписки {email} не назначен тариф") + await callback.message.answer("❌ Для этой подписки тариф не назначен, расширение недоступно.") + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден для email={email}") + await callback.message.answer("❌ Тариф не найден.") + return + + if not tariff.get("configurable"): + logger.info(f"[ADDONS] PACK_MODE: тариф {tariff_id} не конфигурируемый, расширение недоступно") + await callback.message.answer("❌ Для этого тарифа расширение через конфигуратор недоступно.") + return + + cfg = normalize_tariff_config(tariff) + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + device_int_options: list[int] = [] + for value in raw_device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in raw_traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + base_device_limit = cfg.get("base_device_limit") + if base_device_limit is None: + base_device_limit = tariff.get("device_limit") + if base_device_limit is not None: + try: + base_device_int = int(base_device_limit) + if base_device_int not in device_int_options: + raw_device_options.append(base_device_int) + device_int_options.append(base_device_int) + except (TypeError, ValueError): + pass + + device_overrides_cfg = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {} + if "0" in device_overrides_cfg and 0 not in device_int_options: + raw_device_options.append(0) + device_int_options.append(0) + + base_traffic_gb = cfg.get("base_traffic_gb") + if base_traffic_gb is None: + raw_limit = tariff.get("traffic_limit") + if raw_limit: + raw_limit = int(raw_limit) + if raw_limit >= GB: + base_traffic_gb = int(raw_limit / GB) + else: + base_traffic_gb = raw_limit + if base_traffic_gb is not None: + try: + base_traffic_int = int(base_traffic_gb) + if base_traffic_int not in traffic_int_options: + raw_traffic_options.append(base_traffic_int) + traffic_int_options.append(base_traffic_int) + except (TypeError, ValueError): + pass + + traffic_overrides_cfg = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {} + if "0" in traffic_overrides_cfg and 0 not in traffic_int_options: + raw_traffic_options.append(0) + traffic_int_options.append(0) + + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + logger.info( + "[ADDONS] PACK_MODE start_key_addons options: " + f"email={email} tariff_id={tariff_id} " + f"device_options={device_options} traffic_options={traffic_options}" + ) + + selected_device_limit_db = record.get("selected_device_limit") + selected_traffic_limit_db = record.get("selected_traffic_limit") + original_price_db = record.get("selected_price_rub") + current_device_limit_db = record.get("current_device_limit") + current_traffic_limit_db = record.get("current_traffic_limit") + + base_devices = tariff.get("device_limit") + base_devices = int(base_devices) if base_devices is not None else None + + base_traffic_bytes = tariff.get("traffic_limit") + base_traffic_gb = int(base_traffic_bytes / GB) if base_traffic_bytes else None + + current_devices = ( + int(current_device_limit_db) + if current_device_limit_db is not None + else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices) + ) + current_traffic_gb = ( + int(current_traffic_limit_db) + if current_traffic_limit_db is not None + else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb) + ) + + pack_devices, pack_traffic, pack_mode = get_pack_flags() + + has_device_pack = ( + pack_devices and bool(device_options) and not (current_devices is not None and int(current_devices) == 0) + ) + has_traffic_pack = ( + pack_traffic and bool(traffic_options) and not (current_traffic_gb is not None and int(current_traffic_gb) == 0) + ) + + has_any_pack = has_device_pack or has_traffic_pack + if not has_any_pack: + logger.warning( + f"[ADDONS] PACK_MODE: пакеты недоступны, уже максимальные параметры " + f"email={email} current_devices={current_devices} current_traffic_gb={current_traffic_gb} " + f"pack_mode={pack_mode!r}" + ) + await callback.message.answer("❌ Для этой подписки пакеты уже недоступны.") + return + + current_devices_for_price = int(current_devices) if current_devices is not None else None + current_traffic_gb_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None + + config_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_gb_for_price, + ) + + try: + original_price_from_db = int(original_price_db) if original_price_db is not None else 0 + except (TypeError, ValueError): + original_price_from_db = 0 + + original_price = 0 + + logger.debug( + "[ADDONS] PACK_MODE start_key_addons state: " + f"email={email} tariff_id={tariff_id} current_devices={current_devices} current_traffic_gb={current_traffic_gb} " + f"config_price_for_current={config_price_for_current} original_price_db={original_price_db} " + f"original_price={original_price} original_price_from_db={original_price_from_db} " + f"pack_mode={pack_mode!r}" + ) + + cfg_for_state = dict(cfg) + if device_options: + cfg_for_state["device_options"] = device_options + if traffic_options: + cfg_for_state["traffic_options_gb"] = traffic_options + + await state.update_data( + addon_key_email=email, + addon_tariff_id=int(tariff_id), + addon_tariff_config=cfg_for_state, + addon_current_device_limit=current_devices, + addon_current_traffic_gb=current_traffic_gb, + addon_original_price=original_price, + addon_selected_device_limit=None, + addon_selected_traffic_gb=None, + ) + await state.set_state(KeyAddonConfigState.configuring) + + await render_addons_screen(callback, state, session) + + +@router.callback_query(F.data.startswith("key_addons_devices|"), KeyAddonConfigState.configuring) +async def handle_addons_devices_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + parts = callback.data.split("|", 2) + if len(parts) < 3: + await callback.answer("Некорректные данные", show_alert=True) + return + + new_devices = int(parts[2]) + + data = await state.get_data() + selected_devices = data.get("addon_selected_device_limit") + + logger.debug( + "[ADDONS] PACK_MODE handle_addons_devices_choice: " + f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} " + f"new_devices={new_devices} selected_devices={selected_devices}" + ) + + if selected_devices is not None and int(selected_devices) == new_devices: + await callback.answer() + return + + await state.update_data(addon_selected_device_limit=new_devices) + await render_addons_screen(callback, state, session) + + +@router.callback_query(F.data.startswith("key_addons_traffic|"), KeyAddonConfigState.configuring) +async def handle_addons_traffic_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + parts = callback.data.split("|", 2) + if len(parts) < 3: + await callback.answer("Некорректные данные", show_alert=True) + return + + new_traffic_gb = int(parts[2]) + + data = await state.get_data() + selected_traffic_gb = data.get("addon_selected_traffic_gb") + + logger.debug( + "[ADDONS] PACK_MODE handle_addons_traffic_choice: " + f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} " + f"new_traffic_gb={new_traffic_gb} selected_traffic_gb={selected_traffic_gb}" + ) + + if selected_traffic_gb is not None and int(selected_traffic_gb) == new_traffic_gb: + await callback.answer() + return + + await state.update_data(addon_selected_traffic_gb=new_traffic_gb) + await render_addons_screen(callback, state, session) + + +@router.callback_query(F.data == "key_addons_confirm", KeyAddonConfigState.configuring) +async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, session: AsyncSession): + from handlers.keys.operations import renew_key_in_cluster + + tg_id = callback.from_user.id + data = await state.get_data() + + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + original_price = int(data.get("addon_original_price") or 0) + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + current_devices = data.get("addon_current_device_limit") + current_traffic_gb = data.get("addon_current_traffic_gb") + + logger.info( + "[ADDONS] PACK_MODE handle_addons_confirm: " + f"tg_id={tg_id} email={email} tariff_id={tariff_id} original_price={original_price} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " + f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}" + ) + + if not email or not tariff_id: + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return + + record = await get_key_details(session, email) + if not record: + logger.warning(f"[ADDONS] PACK_MODE: подписка {email} не найдена в handle_addons_confirm") + await callback.message.answer("❌ Подписка не найдена.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден в handle_addons_confirm") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + cfg = data.get("addon_tariff_config") or {} + device_options = cfg.get("device_options") or [] + traffic_options = cfg.get("traffic_options_gb") or [] + + pack_devices, pack_traffic, pack_mode = get_pack_flags() + + has_device_option = pack_devices and bool(device_options) + has_traffic_option = pack_traffic and bool(traffic_options) + + if has_device_option and current_devices is not None and int(current_devices) == 0: + has_device_option = False + selected_devices = None + + if has_traffic_option and current_traffic_gb is not None and int(current_traffic_gb) == 0: + has_traffic_option = False + selected_traffic_gb = None + + current_devices_for_price = int(current_devices) if current_devices is not None else None + current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None + + base_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_for_price, + ) + + new_devices_for_price = current_devices_for_price + if has_device_option and selected_devices is not None: + pack_devices_val = int(selected_devices) + if pack_devices_val <= 0 or (new_devices_for_price is not None and new_devices_for_price <= 0): + new_devices_for_price = 0 + else: + new_devices_for_price = (new_devices_for_price or 0) + pack_devices_val + + new_traffic_for_price = current_traffic_for_price + if has_traffic_option and selected_traffic_gb is not None: + pack_traffic_val = int(selected_traffic_gb) + if pack_traffic_val <= 0 or (new_traffic_for_price is not None and new_traffic_for_price <= 0): + new_traffic_for_price = 0 + else: + new_traffic_for_price = (new_traffic_for_price or 0) + pack_traffic_val + + price_with_pack = calculate_config_price( + tariff=tariff, + selected_device_limit=new_devices_for_price, + selected_traffic_gb=new_traffic_for_price, + ) + extra_price = max(0, price_with_pack - base_price_for_current) + + logger.debug( + "[ADDONS] PACK_MODE confirm prices: " + f"base_price_for_current={base_price_for_current} price_with_pack={price_with_pack} " + f"original_price={original_price} extra_price={extra_price} " + f"has_device_option={has_device_option} has_traffic_option={has_traffic_option} " + f"pack_mode={pack_mode!r}" + ) + + if extra_price <= 0: + logger.info(f"[ADDONS] PACK_MODE: extra_price <= 0, доплата не требуется, email={email}") + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(ADDONS_NO_EXTRA_PAYMENT_TEXT, show_alert=True) + return + + balance = await get_balance(session, tg_id) + logger.debug(f"[ADDONS] PACK_MODE balance check: tg_id={tg_id} balance={balance} extra_price={extra_price}") + + if balance < extra_price: + required_amount = ceil(extra_price - balance) + language_code = getattr(callback.from_user, "language_code", None) + required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) + + logger.info( + "[ADDONS] PACK_MODE: недостаточно средств " + f"balance={balance} extra_price={extra_price} required_amount={required_amount} " + f"tg_id={tg_id} USE_NEW_PAYMENT_FLOW={USE_NEW_PAYMENT_FLOW}" + ) + + if USE_NEW_PAYMENT_FLOW: + handled = await try_fast_payment_flow( + callback, + session, + state, + tg_id=tg_id, + temp_key="waiting_for_addons_payment", + temp_payload={ + "email": email, + "tariff_id": int(tariff_id), + "original_price": original_price, + "selected_device_limit": selected_devices, + "selected_traffic_gb": selected_traffic_gb, + "current_device_limit": current_devices, + "current_traffic_gb": current_traffic_gb, + "required_amount": required_amount, + }, + required_amount=required_amount, + ) + logger.debug(f"[ADDONS] PACK_MODE try_fast_payment_flow handled={handled} tg_id={tg_id} email={email}") + if handled: + return + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) + await edit_or_send_message( + target_message=callback.message, + text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text), + reply_markup=builder.as_markup(), + ) + return + + try: + expiry_time = record["expiry_time"] + client_id = record["client_id"] + server_id = record["server_id"] + + device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff_id), + selected_device_limit=int(current_devices) if current_devices is not None else None, + selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None, + ) + traffic_limit_gb_effective_current = ( + int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0 + ) + + new_device_limit_effective = device_limit_effective_current + new_traffic_limit_gb_effective = traffic_limit_gb_effective_current + + if has_device_option and selected_devices is not None: + pack_devices_val = int(selected_devices) + if pack_devices_val <= 0 or (new_device_limit_effective is not None and new_device_limit_effective <= 0): + new_device_limit_effective = 0 + else: + if new_device_limit_effective is None: + new_device_limit_effective = pack_devices_val + else: + new_device_limit_effective = new_device_limit_effective + pack_devices_val + + if has_traffic_option and selected_traffic_gb is not None: + pack_traffic_val = int(selected_traffic_gb) + if pack_traffic_val <= 0 or new_traffic_limit_gb_effective <= 0: + new_traffic_limit_gb_effective = 0 + else: + new_traffic_limit_gb_effective = new_traffic_limit_gb_effective + pack_traffic_val + + current_subgroup = None + try: + current_tariff_id = record.get("tariff_id") + if current_tariff_id: + current_tariff = await get_tariff_by_id(session, int(current_tariff_id)) + if current_tariff: + current_subgroup = current_tariff.get("subgroup_title") + except Exception as error: + logger.warning(f"[ADDONS] PACK_MODE: не удалось определить текущую подгруппу: {error}") + + target_subgroup = tariff.get("subgroup_title") + old_subgroup = current_subgroup + + total_gb = new_traffic_limit_gb_effective + hwid_device_limit_to_set = new_device_limit_effective + + logger.debug( + "[ADDONS] PACK_MODE renew_key_in_cluster params: " + f"server_id={server_id} email={email} client_id={client_id} total_gb={total_gb} " + f"hwid_device_limit_to_set={hwid_device_limit_to_set} target_subgroup={target_subgroup} " + f"old_subgroup={old_subgroup}" + ) + + await renew_key_in_cluster( + cluster_id=server_id, + email=email, + client_id=client_id, + new_expiry_time=expiry_time, + total_gb=total_gb, + session=session, + hwid_device_limit=hwid_device_limit_to_set, + reset_traffic=False, + target_subgroup=target_subgroup, + old_subgroup=old_subgroup, + plan=int(tariff_id), + ) + + await update_balance(session, tg_id, -extra_price) + + await save_key_config_with_mode( + session=session, + email=email, + selected_devices=new_device_limit_effective, + selected_traffic_gb=new_traffic_limit_gb_effective, + total_price=int(price_with_pack), + has_device_choice=has_device_option, + has_traffic_choice=has_traffic_option, + config_mode="pack", + ) + + await session.commit() + + logger.info( + "[ADDONS] PACK_MODE успешная покупка пакета: " + f"tg_id={tg_id} email={email} extra_price={extra_price} " + f"new_device_limit_effective={new_device_limit_effective} " + f"new_traffic_limit_gb_effective={new_traffic_limit_gb_effective} " + f"pack_mode={pack_mode!r}" + ) + + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(ADDONS_PACK_SUCCESS_TEXT, show_alert=True) + + except Exception as error: + logger.error(f"[ADDONS] PACK_MODE ошибка при покупке пакета для {email}: {error}") + await callback.message.answer("❌ Ошибка при обновлении подписки. Попробуйте позже.") + await state.clear() diff --git a/handlers/tariffs/addons/utils.py b/handlers/tariffs/addons/utils.py new file mode 100644 index 00000000..97c6201d --- /dev/null +++ b/handlers/tariffs/addons/utils.py @@ -0,0 +1,166 @@ +from aiogram.fsm.state import State, StatesGroup + +from handlers.texts import ( + ADDONS_CURRENT_HEADER_TEXT, + ADDONS_HINT_BOTH_OPTIONS_TEXT, + ADDONS_HINT_SINGLE_OPTION_TEXT, + ADDONS_NEW_CHOICE_BOTH_TEMPLATE, + ADDONS_NEW_CHOICE_DEVICES_TEMPLATE, + ADDONS_NEW_CHOICE_TRAFFIC_TEMPLATE, + ADDONS_PACK_CURRENT_DEVICES_TEMPLATE, + ADDONS_PACK_CURRENT_TRAFFIC_TEMPLATE, + ADDONS_PACK_EXTRA_PRICE_TEMPLATE, + ADDONS_PACK_HINT_BOTH, + ADDONS_PACK_HINT_DEVICES, + ADDONS_PACK_HINT_TRAFFIC, + ADDONS_PACK_SELECTED_DEVICES_TEMPLATE, + ADDONS_PACK_SELECTED_TRAFFIC_TEMPLATE, + ADDONS_PACK_TITLE_TEMPLATE, + ADDONS_PACK_TOTAL_DEVICES_TEMPLATE, + ADDONS_PACK_TOTAL_TRAFFIC_TEMPLATE, + ADDONS_PRICE_EXTRA_TEMPLATE, + ADDONS_PRICE_TOTAL_TEMPLATE, + ADDONS_TITLE_TEMPLATE, + UNLIMITED_DEVICES_LABEL, + UNLIMITED_TRAFFIC_LABEL, +) + + +class KeyAddonConfigState(StatesGroup): + configuring = State() + + +def format_devices_label(value, default_text: str = "по умолчанию") -> str: + if value is None: + return default_text + value_int = int(value) + if value_int <= 0: + return UNLIMITED_DEVICES_LABEL + return f"{value_int} устройств" + + +def format_traffic_label(value, default_text: str = "по умолчанию") -> str: + if value is None: + return default_text + value_int = int(value) + if value_int <= 0: + return UNLIMITED_TRAFFIC_LABEL + return f"{value_int} ГБ" + + +def is_not_downgrade(current_value, new_value) -> bool: + if current_value is None: + return True + current_int = int(current_value) + new_int = int(new_value) + current_cmp = current_int if current_int > 0 else 10**9 + new_cmp = new_int if new_int > 0 else 10**9 + return new_cmp >= current_cmp + + +def build_addons_screen_text( + *, + tariff_name: str, + current_devices_label: str, + current_traffic_label: str, + new_devices_label: str, + new_traffic_label: str, + has_device_choice: bool, + has_traffic_choice: bool, + total_price_text: str, + extra_price_text: str, + downgrade_warning: str | None = None, +) -> str: + current_text = f"{current_devices_label}, {current_traffic_label}" + + if has_device_choice and has_traffic_choice: + new_choice_line = ADDONS_NEW_CHOICE_BOTH_TEMPLATE.format( + new_devices_label=new_devices_label, + new_traffic_label=new_traffic_label, + ) + hint_line = ADDONS_HINT_BOTH_OPTIONS_TEXT + elif has_device_choice: + new_choice_line = ADDONS_NEW_CHOICE_DEVICES_TEMPLATE.format( + new_devices_label=new_devices_label, + ) + hint_line = ADDONS_HINT_SINGLE_OPTION_TEXT + elif has_traffic_choice: + new_choice_line = ADDONS_NEW_CHOICE_TRAFFIC_TEMPLATE.format( + new_traffic_label=new_traffic_label, + ) + hint_line = ADDONS_HINT_SINGLE_OPTION_TEXT + else: + new_choice_line = "" + hint_line = ADDONS_HINT_SINGLE_OPTION_TEXT + + text = ( + ADDONS_TITLE_TEMPLATE.format(tariff_name=tariff_name) + + "\n\n" + + ADDONS_CURRENT_HEADER_TEXT + + "\n" + + f"
{current_text}\n" + + new_choice_line + + ADDONS_PRICE_TOTAL_TEMPLATE.format(total_price_text=total_price_text) + + "\n" + + ADDONS_PRICE_EXTRA_TEMPLATE.format(extra_price_text=extra_price_text) + + "\n" + ) + + if downgrade_warning: + text += f"\n{downgrade_warning}\n" + + text += f"\n{hint_line}" + return text + + +def build_addons_pack_screen_text( + *, + tariff_name: str, + current_devices_label: str, + current_traffic_label: str | None, + selected_devices_label: str | None, + selected_traffic_label: str | None, + total_devices_label: str | None, + total_traffic_label: str | None, + extra_price_text: str, + has_device_option: bool, + has_traffic_option: bool, +) -> str: + params_lines: list[str] = [] + + params_lines.append(ADDONS_PACK_CURRENT_DEVICES_TEMPLATE.format(value=current_devices_label)) + + if current_traffic_label is not None: + params_lines.append(ADDONS_PACK_CURRENT_TRAFFIC_TEMPLATE.format(value=current_traffic_label)) + + if has_device_option and selected_devices_label is not None: + params_lines.append(ADDONS_PACK_SELECTED_DEVICES_TEMPLATE.format(value=selected_devices_label)) + + if has_traffic_option and selected_traffic_label is not None: + params_lines.append(ADDONS_PACK_SELECTED_TRAFFIC_TEMPLATE.format(value=selected_traffic_label)) + + if has_device_option and total_devices_label is not None: + params_lines.append(ADDONS_PACK_TOTAL_DEVICES_TEMPLATE.format(value=total_devices_label)) + + if has_traffic_option and total_traffic_label is not None: + params_lines.append(ADDONS_PACK_TOTAL_TRAFFIC_TEMPLATE.format(value=total_traffic_label)) + + params_block = "
" + "\n".join(params_lines) + "" + + text_parts: list[str] = [] + text_parts.append(ADDONS_PACK_TITLE_TEMPLATE.format(tariff_name=tariff_name)) + text_parts.append("") + text_parts.append(params_block) + text_parts.append("") + text_parts.append(ADDONS_PACK_EXTRA_PRICE_TEMPLATE.format(value=extra_price_text)) + + if has_device_option or has_traffic_option: + text_parts.append("") + if has_device_option and has_traffic_option: + text_parts.append(ADDONS_PACK_HINT_BOTH) + elif has_traffic_option: + text_parts.append(ADDONS_PACK_HINT_TRAFFIC) + else: + text_parts.append(ADDONS_PACK_HINT_DEVICES) + + return "\n".join(text_parts) diff --git a/handlers/tariffs/buy/key_tariffs.py b/handlers/tariffs/buy/key_tariffs.py new file mode 100644 index 00000000..493e7be2 --- /dev/null +++ b/handlers/tariffs/buy/key_tariffs.py @@ -0,0 +1,936 @@ +from datetime import datetime, timedelta +from math import ceil +from typing import Any + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from config import USE_NEW_PAYMENT_FLOW +from core.settings.tariffs_config import normalize_tariff_config +from database import get_balance, get_tariff_by_id +from database.notifications import check_hot_lead_discount +from handlers.buttons import CONFIG_PAY_BUTTON_TEXT, MAIN_MENU, PAYMENT +from handlers.payments.currency_rates import format_for_user +from handlers.payments.fast_payment_flow import try_fast_payment_flow +from handlers.tariffs.tariff_display import GB +from handlers.texts import ( + CONFIG_SCREEN_TEMPLATE, + CREATING_CONNECTION_MSG, + DEFAULT_LIMIT_LABEL, + INSUFFICIENT_FUNDS_MSG, + UNLIMITED_DEVICES_LABEL, + UNLIMITED_TRAFFIC_LABEL, +) +from handlers.utils import edit_or_send_message +from hooks.processors import process_check_discount_validity +from logger import logger + + +router = Router() + + +CREATING_KEY_BUTTON_TEXT = "⏳ Подождите..." + + +class TariffUserConfigState(StatesGroup): + """Состояния конфигуратора тарифа для пользователя.""" + + configuring = State() + + +def calculate_config_price( + tariff: dict, + selected_device_limit: int | None = None, + selected_traffic_gb: int | None = None, +) -> int: + """Рассчитывает цену тарифа с учётом выбранных лимитов.""" + cfg = normalize_tariff_config(tariff) + + base_price = int(tariff.get("price_rub") or 0) + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + device_values: list[int] = [] + for value in raw_device_options: + try: + device_values.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_values: list[int] = [] + for value in raw_traffic_options: + try: + traffic_values.append(int(value)) + except (TypeError, ValueError): + continue + + positive_device_values = [v for v in device_values if v > 0] + positive_traffic_values = [v for v in traffic_values if v > 0] + + base_device_limit = cfg.get("base_device_limit") + if base_device_limit is None: + base_device_limit = tariff.get("device_limit") + if base_device_limit is None: + if positive_device_values: + base_device_limit = min(positive_device_values) + elif device_values: + base_device_limit = device_values[0] + base_device_limit = int(base_device_limit) if base_device_limit is not None else None + + base_traffic_gb = cfg.get("base_traffic_gb") + if base_traffic_gb is None: + traffic_limit_raw = tariff.get("traffic_limit") + if traffic_limit_raw: + traffic_limit_raw = int(traffic_limit_raw) + if traffic_limit_raw >= GB: + base_traffic_gb = int(traffic_limit_raw / GB) + else: + base_traffic_gb = traffic_limit_raw + else: + if positive_traffic_values: + base_traffic_gb = min(positive_traffic_values) + elif traffic_values: + base_traffic_gb = traffic_values[0] + base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None + + device_overrides = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {} + traffic_overrides = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {} + + extra_device_step_price = int(cfg.get("extra_device_base_price_rub") or tariff.get("device_step_rub") or 0) + extra_traffic_step_price = int( + cfg.get("extra_traffic_base_price_per_gb_rub") or tariff.get("traffic_step_rub") or 0 + ) + + devices_extra_price = 0 + traffic_extra_price = 0 + + if selected_device_limit is not None and base_device_limit is not None: + selected_device_limit = int(selected_device_limit) + override_key = str(selected_device_limit) + + if override_key in device_overrides: + devices_extra_price = int(device_overrides[override_key]) + else: + if selected_device_limit <= 0: + if positive_device_values: + effective_devices = max(positive_device_values) + extra_devices = max(0, effective_devices - base_device_limit) + devices_extra_price = extra_devices * extra_device_step_price + else: + extra_devices = max(0, selected_device_limit - base_device_limit) + devices_extra_price = extra_devices * extra_device_step_price + + if selected_traffic_gb is not None and base_traffic_gb is not None: + selected_traffic_gb = int(selected_traffic_gb) + override_key = str(selected_traffic_gb) + + if override_key in traffic_overrides: + traffic_extra_price = int(traffic_overrides[override_key]) + else: + if selected_traffic_gb <= 0: + if positive_traffic_values: + effective_gb = max(positive_traffic_values) + extra_traffic = max(0, effective_gb - base_traffic_gb) + traffic_extra_price = extra_traffic * extra_traffic_step_price + else: + extra_traffic = max(0, selected_traffic_gb - base_traffic_gb) + traffic_extra_price = extra_traffic * extra_traffic_step_price + + total_price = int(base_price + devices_extra_price + traffic_extra_price) + return total_price + + +async def start_tariff_config( + callback_query: CallbackQuery, + state: FSMContext, + session: Any, + tariff_id: int, +): + """Запускает конфигуратор тарифа по id.""" + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Указанный тариф не найден.", + ) + await callback_query.answer() + logger.warning(f"[TARIFF_CFG] start_tariff_config tariff_not_found: tariff_id={tariff_id}") + return + + if not tariff.get("configurable"): + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Этот тариф нельзя настроить.", + ) + await callback_query.answer() + logger.info(f"[TARIFF_CFG] start_tariff_config not_configurable: tariff_id={tariff_id}") + return + + await start_user_tariff_configurator( + callback_query=callback_query, + session=session, + state=state, + tariff=tariff, + ) + + +async def proceed_purchase_with_values( + callback_query: CallbackQuery, + session: Any, + state: FSMContext, + tariff: dict, + duration_days: int, + price_rub: int, + selected_device_limit: int | None = None, + selected_traffic_gb: int | None = None, +): + """Проверяет баланс и создаёт ключ по выбранной конфигурации.""" + from ...keys.key_create import create_key, moscow_tz + + tg_id = callback_query.from_user.id + + logger.info( + "[TARIFF_CFG] proceed_purchase_with_values: " + f"tg_id={tg_id} tariff_id={tariff.get('id')} duration_days={duration_days} " + f"price_rub={price_rub} selected_device_limit={selected_device_limit} " + f"selected_traffic_gb={selected_traffic_gb}" + ) + + balance = await get_balance(session, tg_id) + + if balance < price_rub: + required_amount = ceil(price_rub - balance) + + logger.info( + f"[TARIFF_CFG] insufficient_balance: tg_id={tg_id} balance={balance} required_amount={required_amount}" + ) + + if USE_NEW_PAYMENT_FLOW: + handled = await try_fast_payment_flow( + callback_query, + session, + state, + tg_id=tg_id, + temp_key="waiting_for_payment", + temp_payload={ + "tariff_id": tariff["id"], + "selected_price_rub": price_rub, + "selected_duration_days": duration_days, + "selected_device_limit": selected_device_limit, + "selected_traffic_limit_gb": selected_traffic_gb, + "required_amount": required_amount, + }, + required_amount=required_amount, + ) + if handled: + return + + language_code = getattr(callback_query.from_user, "language_code", None) + required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + await edit_or_send_message( + target_message=callback_query.message, + text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount_text), + reply_markup=builder.as_markup(), + ) + return + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=CREATING_KEY_BUTTON_TEXT, callback_data="creating_key")) + await edit_or_send_message( + target_message=callback_query.message, + text=CREATING_CONNECTION_MSG, + reply_markup=builder.as_markup(), + ) + await callback_query.answer() + + expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days) + + data_to_update: dict[str, Any] = {"tariff_id": tariff["id"], "selected_price_rub": price_rub} + if selected_device_limit is not None: + data_to_update["config_selected_device_limit"] = selected_device_limit + if selected_traffic_gb is not None: + data_to_update["config_selected_traffic_gb"] = selected_traffic_gb + + await state.update_data(**data_to_update) + + logger.info(f"[TARIFF_CFG] create_key: tg_id={tg_id} tariff_id={tariff.get('id')} expiry_time={expiry_time}") + + await create_key( + tg_id=tg_id, + expiry_time=expiry_time, + state=state, + session=session, + message_or_query=callback_query, + plan=tariff["id"], + selected_duration_days=duration_days, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + selected_price_rub=price_rub, + ) + + +async def render_user_config_screen( + callback_query: CallbackQuery, + state: FSMContext, + session: Any, +): + """Рендерит экран конфигурации тарифа для пользователя.""" + data = await state.get_data() + tariff_id = data.get("config_tariff_id") + cfg = data.get("tariff_config") or {} + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Тариф не найден.", + reply_markup=None, + ) + await state.clear() + logger.warning(f"[TARIFF_CFG] render_user_config_screen tariff_not_found: tariff_id={tariff_id}") + return + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + device_int_options: list[int] = [] + for value in device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + has_device_option = bool(device_int_options) + has_traffic_option = bool(traffic_int_options) + has_device_choice = len(device_int_options) > 1 + has_traffic_choice = len(traffic_int_options) > 1 + + selected_devices = data.get("config_selected_device_limit") + selected_traffic_gb = data.get("config_selected_traffic_gb") + + base_device_limit = cfg.get("base_device_limit") + if base_device_limit is None: + base_device_limit = tariff.get("device_limit") + if base_device_limit is None: + positives = [v for v in device_int_options if v > 0] + if positives: + base_device_limit = min(positives) + elif device_int_options: + base_device_limit = device_int_options[0] + base_device = int(base_device_limit) if base_device_limit is not None else None + + base_traffic_gb = None + raw_base_traffic = tariff.get("traffic_limit") + if raw_base_traffic: + raw_base_traffic = int(raw_base_traffic) + if raw_base_traffic >= GB: + base_traffic_gb = int(raw_base_traffic / GB) + else: + base_traffic_gb = raw_base_traffic + if base_traffic_gb is None: + cfg_base_traffic = cfg.get("base_traffic_gb") + if cfg_base_traffic is not None and int(cfg_base_traffic) > 0: + base_traffic_gb = int(cfg_base_traffic) + if base_traffic_gb is None: + positives = [v for v in traffic_int_options if v > 0] + if positives: + base_traffic_gb = min(positives) + elif traffic_int_options: + base_traffic_gb = traffic_int_options[0] + base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None + + if has_device_option: + if selected_devices is None: + if base_device is not None and base_device in device_int_options: + selected_devices = base_device + elif device_int_options: + selected_devices = device_int_options[0] + else: + selected_devices = None + + if has_traffic_option: + if selected_traffic_gb is None: + if base_traffic_gb is not None and base_traffic_gb in traffic_int_options: + selected_traffic_gb = base_traffic_gb + elif traffic_int_options: + selected_traffic_gb = traffic_int_options[0] + else: + selected_traffic_gb = None + + final_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_option + else None, + ) + + tg_id = callback_query.from_user.id + language_code = getattr(callback_query.from_user, "language_code", None) + price_text = await format_for_user(session, tg_id, float(final_price), language_code) + + base_parts = [] + if base_device is not None: + if int(base_device) <= 0: + base_devices_label = UNLIMITED_DEVICES_LABEL + else: + base_devices_label = f"{int(base_device)} устройств" + base_parts.append(base_devices_label) + + if base_traffic_gb is not None: + if int(base_traffic_gb) <= 0: + base_traffic_label = UNLIMITED_TRAFFIC_LABEL + else: + base_traffic_label = f"{int(base_traffic_gb)} ГБ" + base_parts.append(base_traffic_label) + + if not base_parts: + base_text = DEFAULT_LIMIT_LABEL + else: + base_text = ", ".join(base_parts) + + choice_parts = [] + + if has_device_choice: + if selected_devices is None: + devices_label = DEFAULT_LIMIT_LABEL + else: + if int(selected_devices) <= 0: + devices_label = UNLIMITED_DEVICES_LABEL + else: + devices_label = f"{int(selected_devices)} устройств" + choice_parts.append(devices_label) + + if has_traffic_choice: + if selected_traffic_gb is None: + traffic_label = DEFAULT_LIMIT_LABEL + else: + if selected_traffic_gb <= 0: + traffic_label = UNLIMITED_TRAFFIC_LABEL + else: + traffic_label = f"{int(selected_traffic_gb)} ГБ" + choice_parts.append(traffic_label) + + if not choice_parts: + choice_text = DEFAULT_LIMIT_LABEL + else: + choice_text = ", ".join(choice_parts) + + text = CONFIG_SCREEN_TEMPLATE.format( + base=base_text, + choice=choice_text, + price=price_text, + ) + + builder = InlineKeyboardBuilder() + + device_buttons: list[InlineKeyboardButton] = [] + traffic_buttons: list[InlineKeyboardButton] = [] + + if has_device_choice: + selected_devices_int = int(selected_devices or 0) + for value in device_int_options: + mark = " ✅" if value == selected_devices_int else "" + if value == 0: + caption = f"{UNLIMITED_DEVICES_LABEL.capitalize()}{mark}" + else: + caption = f"{value} устройств{mark}" + device_buttons.append( + InlineKeyboardButton( + text=caption, + callback_data=f"cfg_user_devices|{tariff_id}|{value}", + ) + ) + + if has_traffic_choice: + selected_traffic_int = int(selected_traffic_gb or 0) + for value in traffic_int_options: + mark = " ✅" if value == selected_traffic_int else "" + if value == 0: + caption = f"{UNLIMITED_TRAFFIC_LABEL.capitalize()}{mark}" + else: + caption = f"{value} ГБ{mark}" + traffic_buttons.append( + InlineKeyboardButton( + text=caption, + callback_data=f"cfg_user_traffic|{tariff_id}|{value}", + ) + ) + + if device_buttons and traffic_buttons: + max_len = max(len(device_buttons), len(traffic_buttons)) + for i in range(max_len): + row = [] + if i < len(device_buttons): + row.append(device_buttons[i]) + if i < len(traffic_buttons): + row.append(traffic_buttons[i]) + builder.row(*row) + elif device_buttons: + for b in device_buttons: + builder.row(b) + elif traffic_buttons: + for b in traffic_buttons: + builder.row(b) + + is_renew_mode = data.get("renew_mode") == "renew" + confirm_prefix = "cfg_renew_confirm" if is_renew_mode else "cfg_user_confirm" + + builder.row( + InlineKeyboardButton( + text=CONFIG_PAY_BUTTON_TEXT.format(amount=price_text), + callback_data=f"{confirm_prefix}|{tariff_id}", + ) + ) + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + + await state.update_data( + config_selected_device_limit=selected_devices, + config_selected_traffic_gb=selected_traffic_gb, + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=text, + reply_markup=builder.as_markup(), + ) + await callback_query.answer() + + +async def start_user_tariff_configurator( + callback_query: CallbackQuery, + session: Any, + state: FSMContext, + tariff: dict, +): + """Запускает конфигуратор тарифа для пользователя.""" + cfg = normalize_tariff_config(tariff) + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + + device_int_options: list[int] = [] + for value in raw_device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in raw_traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + base_device_limit = cfg.get("base_device_limit") + if base_device_limit is None: + base_device_limit = tariff.get("device_limit") + if base_device_limit is not None: + try: + base_device_int = int(base_device_limit) + if base_device_int not in device_int_options: + raw_device_options.append(base_device_int) + device_int_options.append(base_device_int) + except (TypeError, ValueError): + pass + + device_overrides_cfg = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {} + if "0" in device_overrides_cfg and 0 not in device_int_options: + raw_device_options.append(0) + device_int_options.append(0) + + base_traffic_gb = cfg.get("base_traffic_gb") + if base_traffic_gb is None: + raw_limit = tariff.get("traffic_limit") + if raw_limit: + raw_limit = int(raw_limit) + if raw_limit >= GB: + base_traffic_gb = int(raw_limit / GB) + else: + base_traffic_gb = raw_limit + if base_traffic_gb is not None: + try: + base_traffic_int = int(base_traffic_gb) + if base_traffic_int not in traffic_int_options: + raw_traffic_options.append(base_traffic_int) + traffic_int_options.append(base_traffic_int) + except (TypeError, ValueError): + pass + + traffic_overrides_cfg = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {} + if "0" in traffic_overrides_cfg and 0 not in traffic_int_options: + raw_traffic_options.append(0) + traffic_int_options.append(0) + + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + logger.info( + "[TARIFF_CFG] start_user_tariff_configurator: " + f"tg_id={callback_query.from_user.id} tariff_id={tariff.get('id')} " + f"device_options={device_options} traffic_options={traffic_options}" + ) + + if not device_options and not traffic_options: + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Конфигуратор для этого тарифа не настроен. Попробуйте выбрать другой тариф.", + reply_markup=None, + ) + await callback_query.answer() + logger.warning(f"[TARIFF_CFG] configurator_not_configured: tariff_id={tariff.get('id')}") + return + + cfg_for_state = dict(cfg) + if device_options: + cfg_for_state["device_options"] = device_options + if traffic_options: + cfg_for_state["traffic_options_gb"] = traffic_options + + data = await state.get_data() + renew_mode = data.get("renew_mode") + + update_payload: dict[str, Any] = { + "config_tariff_id": tariff["id"], + "tariff_config": cfg_for_state, + } + if renew_mode != "renew": + update_payload["config_selected_device_limit"] = None + update_payload["config_selected_traffic_gb"] = None + + await state.update_data(**update_payload) + await state.set_state(TariffUserConfigState.configuring) + + await render_user_config_screen(callback_query, state, session) + + +async def show_price_and_confirm(callback_query: CallbackQuery, state: FSMContext, session: Any | None): + """Обновляет экран конфигурации и показывает актуальную цену.""" + if session is None: + from database import async_session_maker + + async with async_session_maker() as new_session: + await show_price_and_confirm(callback_query, state, new_session) + return + + await render_user_config_screen(callback_query, state, session) + + +async def finalize_config_and_purchase(callback_query: CallbackQuery, state: FSMContext, session: Any | None): + """Фиксирует выбор пользователя и проводит оплату тарифа.""" + data = await state.get_data() + tariff_id = data.get("config_tariff_id") + cfg = data.get("tariff_config") or {} + + if session is None: + from database import async_session_maker + + async with async_session_maker() as new_session: + await finalize_config_and_purchase(callback_query, state, new_session) + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Тариф не найден.", + reply_markup=None, + ) + await state.clear() + logger.warning(f"[TARIFF_CFG] finalize_config_and_purchase tariff_not_found: tariff_id={tariff_id}") + return + + duration_days = int(tariff.get("duration_days") or 30) + + raw_device_options = cfg.get("device_options") or tariff.get("device_options") or [] + try: + device_options = sorted( + raw_device_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + device_options = raw_device_options + + raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or [] + try: + traffic_options = sorted( + raw_traffic_options, + key=lambda v: (int(v) == 0, int(v)), + ) + except (TypeError, ValueError): + traffic_options = raw_traffic_options + + device_int_options: list[int] = [] + for value in device_options: + try: + device_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + traffic_int_options: list[int] = [] + for value in traffic_options: + try: + traffic_int_options.append(int(value)) + except (TypeError, ValueError): + continue + + has_device_choice = bool(device_int_options) + has_traffic_choice = bool(traffic_int_options) + + selected_devices = data.get("config_selected_device_limit") + selected_traffic_gb = data.get("config_selected_traffic_gb") + + base_device_limit = cfg.get("base_device_limit") + if base_device_limit is None: + base_device_limit = tariff.get("device_limit") + if base_device_limit is None: + positives = [v for v in device_int_options if v > 0] + if positives: + base_device_limit = min(positives) + elif device_int_options: + base_device_limit = device_int_options[0] + base_device = int(base_device_limit) if base_device_limit is not None else None + + base_traffic_gb = None + raw_base_traffic = tariff.get("traffic_limit") + if raw_base_traffic: + raw_base_traffic = int(raw_base_traffic) + if raw_base_traffic >= GB: + base_traffic_gb = int(raw_base_traffic / GB) + else: + base_traffic_gb = raw_base_traffic + if base_traffic_gb is None: + cfg_base_traffic = cfg.get("base_traffic_gb") + if cfg_base_traffic is not None and int(cfg_base_traffic) > 0: + base_traffic_gb = int(cfg_base_traffic) + if base_traffic_gb is None: + positives = [v for v in traffic_int_options if v > 0] + if positives: + base_traffic_gb = min(positives) + elif traffic_int_options: + base_traffic_gb = traffic_int_options[0] + base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None + + if has_device_choice: + if selected_devices is None: + if base_device is not None and base_device in device_int_options: + selected_devices = base_device + elif device_int_options: + selected_devices = device_int_options[0] + else: + selected_devices = None + + if has_traffic_choice: + if selected_traffic_gb is None: + if base_traffic_gb is not None and base_traffic_gb in traffic_int_options: + selected_traffic_gb = base_traffic_gb + elif traffic_int_options: + selected_traffic_gb = traffic_int_options[0] + else: + selected_traffic_gb = None + + final_price = calculate_config_price( + tariff=tariff, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_choice else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_choice + else None, + ) + + logger.info( + "[TARIFF_CFG] finalize_config_and_purchase: " + f"tg_id={callback_query.from_user.id} tariff_id={tariff_id} duration_days={duration_days} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " + f"final_price={final_price}" + ) + + await proceed_purchase_with_values( + callback_query=callback_query, + session=session, + state=state, + tariff=tariff, + duration_days=duration_days, + price_rub=final_price, + selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_choice else None, + selected_traffic_gb=int(selected_traffic_gb) + if selected_traffic_gb is not None and has_traffic_choice + else None, + ) + + +@router.callback_query(F.data.startswith("select_tariff_plan|")) +async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext): + """Обрабатывает выбор тарифа пользователем.""" + tg_id = callback_query.from_user.id + tariff_id = int(callback_query.data.split("|")[1]) + + logger.info("[TARIFF_CFG] select_tariff_plan: tg_id=%s tariff_id=%s", tg_id, tariff_id) + + tariff = await get_tariff_by_id(session, tariff_id) + if not tariff: + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Указанный тариф не найден.", + ) + await callback_query.answer() + logger.warning(f"[TARIFF_CFG] select_tariff_plan tariff_not_found: tariff_id={tariff_id}") + return + + discount_info = await check_hot_lead_discount(session, tg_id) + if tariff.get("group_code") in ["discounts", "discounts_max"]: + if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]: + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.", + reply_markup=builder.as_markup(), + ) + await callback_query.answer() + logger.info( + "[TARIFF_CFG] select_tariff_plan discount_invalid: " + f"tg_id={tg_id} tariff_id={tariff_id} info={discount_info}" + ) + return + + validity_result = await process_check_discount_validity( + chat_id=tg_id, + admin=False, + session=session, + tariff_group=tariff.get("group_code"), + ) + if validity_result: + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) + await edit_or_send_message( + target_message=callback_query.message, + text=validity_result["message"], + reply_markup=builder.as_markup(), + ) + await callback_query.answer() + logger.info( + "[TARIFF_CFG] select_tariff_plan discount_validity_failed: " + f"tg_id={tg_id} tariff_id={tariff_id} result={validity_result}" + ) + return + + if tariff.get("configurable"): + logger.info(f"[TARIFF_CFG] select_tariff_plan configurable: tg_id={tg_id} tariff_id={tariff_id}") + try: + await start_user_tariff_configurator(callback_query, session=session, state=state, tariff=tariff) + except Exception as error: + logger.error(f"[TARIFF_CFG] error_in_configurator: tariff_id={tariff_id} error={error}") + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Ошибка конфигурации тарифа. Попробуйте позже.", + reply_markup=None, + ) + return + + duration_days = int(tariff.get("duration_days") or 30) + price_rub = int(tariff.get("price_rub") or 0) + selected_device_limit = tariff.get("device_limit") + + raw_traffic_limit = tariff.get("traffic_limit") + selected_traffic_gb = None + if raw_traffic_limit: + raw_traffic_limit = int(raw_traffic_limit) + if raw_traffic_limit >= GB: + selected_traffic_gb = int(raw_traffic_limit / GB) + else: + selected_traffic_gb = raw_traffic_limit + + logger.info( + "[TARIFF_CFG] select_tariff_plan fixed_tariff: " + f"tg_id={tg_id} tariff_id={tariff_id} duration_days={duration_days} " + f"price_rub={price_rub} device_limit={selected_device_limit} " + f"selected_traffic_gb={selected_traffic_gb}" + ) + + await proceed_purchase_with_values( + callback_query=callback_query, + session=session, + state=state, + tariff=tariff, + duration_days=duration_days, + price_rub=price_rub, + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + ) + + +@router.callback_query( + F.data.startswith("cfg_user_devices|"), + TariffUserConfigState.configuring, +) +async def handle_user_devices_choice(callback: CallbackQuery, state: FSMContext, session: Any): + """Обрабатывает выбор лимита устройств в конфигураторе.""" + _, _tariff_id_str, devices_str = callback.data.split("|", 2) + devices = int(devices_str) + + await state.update_data(config_selected_device_limit=devices) + await render_user_config_screen(callback, state, session) + + +@router.callback_query( + F.data.startswith("cfg_user_traffic|"), + TariffUserConfigState.configuring, +) +async def handle_user_traffic_choice(callback: CallbackQuery, state: FSMContext, session: Any): + """Обрабатывает выбор лимита трафика в конфигураторе.""" + _, _tariff_id_str, traffic_str = callback.data.split("|", 2) + traffic = int(traffic_str) + + await state.update_data(config_selected_traffic_gb=traffic) + await render_user_config_screen(callback, state, session=session) + + +@router.callback_query(F.data.startswith("cfg_user_confirm|"), TariffUserConfigState.configuring) +async def handle_user_config_confirm(callback: CallbackQuery, state: FSMContext, session: Any): + """Подтверждает выбор параметров тарифа и запускает покупку.""" + logger.info(f"[TARIFF_CFG] handle_user_config_confirm: tg_id={callback.from_user.id}") + await finalize_config_and_purchase(callback, state, session=session) diff --git a/handlers/tariffs/tariff_display.py b/handlers/tariffs/tariff_display.py new file mode 100644 index 00000000..afb6b3ad --- /dev/null +++ b/handlers/tariffs/tariff_display.py @@ -0,0 +1,348 @@ +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD +from core.settings.tariffs_config import TARIFFS_CONFIG, normalize_tariff_config +from database import get_servers, get_tariff_by_id +from database.models import Key +from handlers.texts import key_message_success +from logger import logger + + +GB = 1024 * 1024 * 1024 + + +async def get_effective_limits_for_key( + session: AsyncSession, + tariff_id: int | None, + selected_device_limit: int | None, + selected_traffic_gb: int | None, +) -> tuple[int, int]: + tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None + + if tariff: + base_devices = tariff.get("device_limit") + base_traffic_bytes = tariff.get("traffic_limit") + else: + base_devices = None + base_traffic_bytes = None + + if selected_device_limit is None: + device_limit = int(base_devices or 0) + elif selected_device_limit == 0: + device_limit = 0 + else: + device_limit = int(selected_device_limit) + + if selected_traffic_gb is None: + traffic_limit_bytes = int(base_traffic_bytes or 0) + elif selected_traffic_gb == 0: + traffic_limit_bytes = 0 + else: + traffic_limit_bytes = int(selected_traffic_gb) * GB + + return device_limit, traffic_limit_bytes + + +async def resolve_price_to_charge(session: AsyncSession, state_data: dict[str, Any]) -> int | None: + price = state_data.get("selected_price_rub") + if price is not None: + try: + return int(price) + except (TypeError, ValueError): + return None + + tariff_id = state_data.get("tariff_id") + if not tariff_id: + return None + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + return None + + try: + return int(tariff.get("price_rub") or 0) + except (TypeError, ValueError): + return None + + +async def resolve_vless_enabled(session: AsyncSession, tariff_id: int | None) -> bool: + if not tariff_id: + return False + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + return False + + return bool(tariff.get("vless")) + + +async def get_key_tariff_display( + session: AsyncSession, + key_record: dict[str, Any], + selected_device_limit_override: int | None = None, + selected_traffic_gb_override: int | None = None, +) -> tuple[str, str, int, int, bool]: + tariff_id = key_record.get("tariff_id") + if not tariff_id: + return "", "", 0, 0, False + + selected_device_limit = selected_device_limit_override + selected_traffic_gb = selected_traffic_gb_override + + if selected_device_limit is None: + value = key_record.get("selected_device_limit") + if value is not None: + try: + selected_device_limit = int(value) + except (TypeError, ValueError): + selected_device_limit = None + + if selected_traffic_gb is None: + value = key_record.get("selected_traffic_limit") + if value is not None: + try: + selected_traffic_gb = int(value) + except (TypeError, ValueError): + selected_traffic_gb = None + + device_limit, traffic_limit_bytes = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff_id), + selected_device_limit=selected_device_limit, + selected_traffic_gb=selected_traffic_gb, + ) + + server_cluster_id = key_record.get("server_id") + client_id = key_record.get("client_id") + + if server_cluster_id and client_id: + try: + servers = await get_servers(session) + cluster_servers = servers.get(server_cluster_id) or servers.get(str(server_cluster_id)) or [] + remna_server = next((srv for srv in cluster_servers if srv.get("panel_type") == "remnawave"), None) + if not remna_server: + remna_server = next( + (srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), + None, + ) + + if remna_server: + from panels.remnawave import RemnawaveAPI + + api = RemnawaveAPI(remna_server["api_url"]) + try: + ok = await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD) + except Exception as e: + logger.warning(f"[KeyTariffDisplay] Remnawave login error for {client_id}: {e}") + ok = False + + if ok: + try: + user_data = await api.get_user_by_uuid(client_id) + except Exception as e: + logger.warning(f"[KeyTariffDisplay] Remnawave get_user_by_uuid error for {client_id}: {e}") + user_data = None + + if user_data: + panel_traffic_limit_bytes = user_data.get("trafficLimitBytes") + panel_device_limit = user_data.get("hwidDeviceLimit") + + if panel_traffic_limit_bytes is not None: + try: + traffic_limit_bytes = int(panel_traffic_limit_bytes) + except (TypeError, ValueError): + logger.warning( + f"[KeyTariffDisplay] Invalid trafficLimitBytes from Remnawave for {client_id}: {panel_traffic_limit_bytes}" + ) + + if panel_device_limit is not None: + try: + device_limit = int(panel_device_limit) + except (TypeError, ValueError): + logger.warning( + f"[KeyTariffDisplay] Invalid hwidDeviceLimit from Remnawave for {client_id}: {panel_device_limit}" + ) + + try: + await api.aclose() + except Exception: + pass + except Exception as e: + logger.warning(f"[KeyTariffDisplay] Error while overriding limits from panel: {e}") + + traffic_limit_gb = int(traffic_limit_bytes / GB) if traffic_limit_bytes else 0 + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if tariff: + tariff_name = tariff.get("name", "—") + subgroup_title = tariff.get("subgroup_title") or "" + vless_enabled = bool(tariff.get("vless")) + else: + tariff_name = "—" + subgroup_title = "" + vless_enabled = False + + return tariff_name, subgroup_title, traffic_limit_gb, device_limit, vless_enabled + + +async def get_key_tariff_addons_state( + session: AsyncSession, + key_record: dict[str, Any], + db_key: Key | None, +) -> tuple[str, str, int, int, bool, bool, bool, bool]: + """Возвращает параметры тарифа и допы для ключа.""" + tariff_id = key_record.get("tariff_id") + if not tariff_id: + return "", "", 0, 0, False, False, False, False + + selected_device_limit_override: int | None = None + selected_traffic_gb_override: int | None = None + + if db_key: + if db_key.selected_device_limit is not None: + try: + selected_device_limit_override = int(db_key.selected_device_limit) + except (TypeError, ValueError): + selected_device_limit_override = None + if db_key.selected_traffic_limit is not None: + try: + selected_traffic_gb_override = int(db_key.selected_traffic_limit) + except (TypeError, ValueError): + selected_traffic_gb_override = None + + ( + tariff_name, + subgroup_title, + traffic_limit_gb, + device_limit, + vless_enabled, + ) = await get_key_tariff_display( + session=session, + key_record=key_record, + selected_device_limit_override=selected_device_limit_override, + selected_traffic_gb_override=selected_traffic_gb_override, + ) + + unlimited_devices = device_limit == 0 + unlimited_traffic = traffic_limit_gb == 0 + + if unlimited_devices or unlimited_traffic: + suffix_parts: list[str] = [] + if unlimited_traffic: + suffix_parts.append("безлимит трафика") + if unlimited_devices: + suffix_parts.append("безлимит устройств") + tariff_name = f"{tariff_name} ({', '.join(suffix_parts)})" + + is_tariff_configurable = False + addons_devices_enabled = False + addons_traffic_enabled = False + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if tariff and tariff.get("configurable"): + is_tariff_configurable = True + + cfg = normalize_tariff_config(tariff) + device_options = cfg.get("device_options") or [] + traffic_options = cfg.get("traffic_options_gb") or [] + + addons_devices_enabled = bool(device_options) + addons_traffic_enabled = bool(traffic_options) + + mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or "" + if not mode: + pass + elif mode == "traffic": + addons_devices_enabled = False + elif mode == "devices": + addons_traffic_enabled = False + elif mode == "all": + pass + else: + logger.warning(f"Некорректный KEY_ADDONS_PACK_MODE: {mode!r}") + + if unlimited_devices: + addons_devices_enabled = False + if unlimited_traffic: + addons_traffic_enabled = False + + return ( + tariff_name, + subgroup_title, + traffic_limit_gb, + device_limit, + vless_enabled, + is_tariff_configurable, + addons_devices_enabled, + addons_traffic_enabled, + ) + + +async def build_key_created_message( + session: AsyncSession, + key_record: dict[str, Any], + final_link: str, + selected_device_limit: int | None = None, + selected_traffic_gb: int | None = None, +) -> str: + tariff_id = key_record.get("tariff_id") + tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None + + if tariff: + tariff_name = tariff.get("name", "—") + subgroup_title = tariff.get("subgroup_title") or "" + base_traffic = tariff.get("traffic_limit") + base_devices = tariff.get("device_limit") + else: + tariff_name = "—" + subgroup_title = "" + base_traffic = None + base_devices = None + + if selected_traffic_gb is not None: + try: + traffic_to_show = int(selected_traffic_gb) + except (TypeError, ValueError): + traffic_to_show = 0 + else: + selected_traffic_limit = key_record.get("selected_traffic_limit") + if selected_traffic_limit is not None: + try: + traffic_to_show = int(selected_traffic_limit) + except (TypeError, ValueError): + traffic_to_show = 0 + elif base_traffic is not None: + try: + traffic_to_show = int(base_traffic / GB) + except (TypeError, ValueError): + traffic_to_show = 0 + else: + traffic_to_show = 0 + + if selected_device_limit is not None: + try: + devices_to_show = int(selected_device_limit) + except (TypeError, ValueError): + devices_to_show = 0 + else: + selected_device_limit_db = key_record.get("selected_device_limit") + if selected_device_limit_db is not None: + try: + devices_to_show = int(selected_device_limit_db) + except (TypeError, ValueError): + devices_to_show = 0 + else: + try: + devices_to_show = int(base_devices) if base_devices is not None else 0 + except (TypeError, ValueError): + devices_to_show = 0 + + return key_message_success( + final_link or "Ссылка не найдена", + tariff_name=tariff_name, + traffic_limit=traffic_to_show, + device_limit=devices_to_show, + subgroup_title=subgroup_title, + ) diff --git a/hooks/processors.py b/hooks/processors.py index 6db106e0..4ccd4dfd 100644 --- a/hooks/processors.py +++ b/hooks/processors.py @@ -1,6 +1,7 @@ from typing import Any from logger import logger + from .hooks import run_hooks @@ -13,7 +14,7 @@ async def process_cluster_override( ) -> str | None: """ Обрабатывает хук cluster_override. - + Возвращает название кластера для принудительного выбора или None. """ try: @@ -38,7 +39,7 @@ async def process_cluster_balancer( ) -> dict | None: """ Обрабатывает хук cluster_balancer. - + Возвращает отфильтрованный словарь кластеров или None (использовать исходный). """ try: @@ -62,7 +63,7 @@ async def process_remnawave_webapp_override( ) -> bool: """ Обрабатывает хук remnawave_webapp_override. - + Возвращает bool - использовать ли webapp для подключения устройства. """ if not remnawave_webapp or not final_link: @@ -102,7 +103,7 @@ async def process_happ_cryptolink_override( ) -> bool: """ Обрабатывает хук happ_cryptolink_override. - + Возвращает bool - использовать ли криптоссылку для подписки. """ try: @@ -141,15 +142,15 @@ async def process_extract_cryptolink_from_result( ) -> str | None: """ Обрабатывает хук happ_cryptolink_override и извлекает криптоссылку из результата API. - + Возвращает криптоссылку если нужно использовать, иначе None. """ if need_vless_key: return None try: - from core.bootstrap import MODES_CONFIG from config import HAPP_CRYPTOLINK + from core.bootstrap import MODES_CONFIG base_use_crypto_link = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)) use_crypto_link = await process_happ_cryptolink_override( @@ -188,7 +189,7 @@ async def process_get_cryptolink_after_renewal( ) -> str | None: """ Получает свежие данные подписки после продления и извлекает криптоссылку если нужно. - + Возвращает криптоссылку если хук требует её использования, иначе None. """ if not remnawave_nodes: @@ -196,9 +197,9 @@ async def process_get_cryptolink_after_renewal( try: from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD - from panels.remnawave import RemnawaveAPI from database import get_tariff_by_id from handlers.keys.operations.utils import is_plan_vless + from panels.remnawave import RemnawaveAPI remna = RemnawaveAPI(remnawave_nodes[0]["api_url"]) if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD): @@ -237,7 +238,7 @@ async def process_intercept_key_creation_message( ) -> bool: """ Обрабатывает хук intercept_key_creation_message. - + Возвращает True если нужно прервать выполнение (перехватить сообщение). """ try: @@ -264,7 +265,7 @@ async def process_key_creation_complete( ) -> list: """ Обрабатывает хук key_creation_complete. - + Возвращает список кнопок для добавления в меню после создания ключа. """ try: @@ -291,7 +292,7 @@ async def process_process_callback_renew_key( ) -> list: """ Обрабатывает хук process_callback_renew_key. - + Возвращает список кнопок для добавления в меню продления. """ try: @@ -316,7 +317,7 @@ async def process_renewal_forbidden_groups( ) -> list[str]: """ Обрабатывает хук renewal_forbidden_groups. - + Возвращает список дополнительных запрещенных групп для продления. """ try: @@ -348,11 +349,11 @@ async def process_purchase_tariff_group_override( ) -> dict | None: """ Обрабатывает хук purchase_tariff_group_override. - + Возвращает dict с ключами: - override_group: str - новая группа тарифов - discount_info: dict | None - информация о скидке (опционально) - + Или None если переопределение не требуется. """ try: @@ -384,7 +385,7 @@ async def process_renew_tariffs( ) -> list: """ Обрабатывает хук renew_tariffs. - + Возвращает список кнопок для добавления в меню выбора тарифов для продления. """ try: @@ -411,7 +412,7 @@ async def process_renewal_complete( ) -> list: """ Обрабатывает хук renewal_complete. - + Возвращает список кнопок для добавления в меню после продления подписки. """ try: @@ -437,7 +438,7 @@ async def process_view_key_menu( ) -> list: """ Обрабатывает хук view_key_menu. - + Возвращает список кнопок для добавления в меню просмотра ключа. """ try: @@ -460,7 +461,7 @@ async def process_admin_key_edit_menu( ) -> list: """ Обрабатывает хук admin_key_edit_menu. - + Возвращает список кнопок для добавления в меню редактирования ключа в админке. """ try: @@ -485,7 +486,7 @@ async def process_after_hwid_reset( ) -> bool: """ Обрабатывает хук after_hwid_reset. - + Возвращает True если нужно перенаправить пользователя в профиль после сброса устройств. """ try: @@ -514,7 +515,7 @@ async def process_tariff_menu( ) -> list: """ Обрабатывает хук tariff_menu. - + Возвращает список кнопок для добавления в меню выбора тарифов. """ try: @@ -541,11 +542,11 @@ async def process_check_discount_validity( ) -> dict | None: """ Обрабатывает хук check_discount_validity. - + Возвращает dict с ключами: - valid: bool - валидна ли скидка - message: str - сообщение об ошибке (если valid=False) - + Или None если скидка валидна. """ try: @@ -577,7 +578,7 @@ async def process_connect_device_menu( ) -> list: """ Обрабатывает хук connect_device_menu. - + Возвращает список кнопок для добавления в меню подключения устройства. """ try: @@ -592,4 +593,3 @@ async def process_connect_device_menu( except Exception as e: logger.warning(f"[CONNECT_DEVICE_MENU] Ошибка при обработке хука: {e}") return [] - diff --git a/middlewares/maintenance.py b/middlewares/maintenance.py index 7353f595..e1477a33 100644 --- a/middlewares/maintenance.py +++ b/middlewares/maintenance.py @@ -5,7 +5,7 @@ from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, Update from config import ADMIN_ID -from core.bootstrap import MANAGEGENT_CONFIG +from core.bootstrap import MANAGEMENT_CONFIG from database import async_session_maker from database.models import Admin @@ -17,7 +17,7 @@ class MaintenanceModeMiddleware(BaseMiddleware): event: Update, data: dict[str, Any], ) -> Any: - maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False)) + maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)) if not maintenance_enabled: return await handler(event, data) diff --git a/servers.py b/servers.py index 89a900ef..63b9f90d 100644 --- a/servers.py +++ b/servers.py @@ -23,10 +23,9 @@ PING_SEMAPHORE = asyncio.Semaphore(3) async def ping_server(server_ip: str) -> bool: - """Пингует сервер через ICMP или TCP 443, если ICMP недоступен.""" async with PING_SEMAPHORE: try: - response = ping(server_ip, timeout=3) + response = await asyncio.to_thread(ping, server_ip, timeout=3) if response is not None and response is not False: return True return await check_tcp_connection(server_ip, 443) diff --git a/utils/csv_export.py b/utils/csv_export.py index 79589342..6bb6d51c 100644 --- a/utils/csv_export.py +++ b/utils/csv_export.py @@ -7,8 +7,8 @@ from aiogram.types import BufferedInputFile from sqlalchemy import exists, func, join, not_, select from sqlalchemy.ext.asyncio import AsyncSession -from database.models import Key, Payment, Referral, Tariff, User from core.constants import PAYMENT_SYSTEMS_EXCLUDED +from database.models import Key, Payment, Referral, Tariff, User async def export_users_csv(session: AsyncSession) -> BufferedInputFile: diff --git a/utils/versioning.py b/utils/versioning.py index d319ff6c..534d0524 100644 --- a/utils/versioning.py +++ b/utils/versioning.py @@ -92,4 +92,4 @@ def get_git_commit_number() -> str: def get_version() -> str: - return f"v.5.1-preAlpha {get_git_commit_number()}" + return f"v.5.1-a291141 {get_git_commit_number()}"