rate configurator/ purchase of conditions/ fixed payments/ refactoring/ major improvements and fixes

This commit is contained in:
Vladless
2025-11-29 19:08:01 +03:00
parent 28cfe91175
commit f2d14d69b0
90 changed files with 5888 additions and 799 deletions
+6 -3
View File
@@ -1,10 +1,12 @@
from database import async_session_maker from database import async_session_maker
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config 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.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.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: async def bootstrap() -> None:
@@ -15,4 +17,5 @@ async def bootstrap() -> None:
await load_payments_config(session) await load_payments_config(session)
await load_money_config(session) await load_money_config(session)
await load_management_config(session) await load_management_config(session)
await load_tariffs_config(session)
await session.commit() await session.commit()
+6 -1
View File
@@ -2,10 +2,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting from database.models import Setting
from ..defaults import DEFAULT_BUTTONS_CONFIG from ..defaults import DEFAULT_BUTTONS_CONFIG
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy() 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: async def load_buttons_config(session: AsyncSession) -> None:
@@ -15,6 +17,7 @@ async def load_buttons_config(session: AsyncSession) -> None:
if setting is None: if setting is None:
buttons_config = DEFAULT_BUTTONS_CONFIG.copy() buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
setting = Setting( setting = Setting(
key="BUTTONS_CONFIG", key="BUTTONS_CONFIG",
value=buttons_config, value=buttons_config,
@@ -25,6 +28,7 @@ async def load_buttons_config(session: AsyncSession) -> None:
stored = setting.value or {} stored = setting.value or {}
buttons_config = DEFAULT_BUTTONS_CONFIG.copy() buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(stored) buttons_config.update(stored)
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
setting.value = buttons_config setting.value = buttons_config
BUTTONS_CONFIG.clear() BUTTONS_CONFIG.clear()
@@ -47,10 +51,11 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo
else: else:
setting.value = new_values setting.value = new_values
await session.flush() await session.commit()
buttons_config = DEFAULT_BUTTONS_CONFIG.copy() buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(new_values) buttons_config.update(new_values)
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
BUTTONS_CONFIG.clear() BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config) BUTTONS_CONFIG.update(buttons_config)
+11 -10
View File
@@ -4,21 +4,23 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting from database.models import Setting
from ..defaults import DEFAULT_MANAGEMENT_CONFIG 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: 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) result = await session.execute(stmt)
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if setting is None: if setting is None:
management_config = DEFAULT_MANAGEMENT_CONFIG.copy() management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
setting = Setting( setting = Setting(
key="management", key=MANAGEMENT_SETTING_KEY,
value=management_config, value=management_config,
description="Конфигурация управления ботом", description="Конфигурация управления ботом",
) )
@@ -29,19 +31,19 @@ async def load_management_config(session: AsyncSession) -> None:
management_config.update(stored) management_config.update(stored)
setting.value = management_config setting.value = management_config
MANAGEGENT_CONFIG.clear() MANAGEMENT_CONFIG.clear()
MANAGEGENT_CONFIG.update(management_config) MANAGEMENT_CONFIG.update(management_config)
await session.flush() await session.flush()
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None: 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) result = await session.execute(stmt)
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if setting is None: if setting is None:
setting = Setting( setting = Setting(
key="management", key=MANAGEMENT_SETTING_KEY,
value=new_values, value=new_values,
description="Конфигурация управления ботом", description="Конфигурация управления ботом",
) )
@@ -49,11 +51,10 @@ async def update_management_config(session: AsyncSession, new_values: dict[str,
else: else:
setting.value = new_values setting.value = new_values
await session.flush()
await session.commit() await session.commit()
management_config = DEFAULT_MANAGEMENT_CONFIG.copy() management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
management_config.update(new_values) management_config.update(new_values)
MANAGEGENT_CONFIG.clear() MANAGEMENT_CONFIG.clear()
MANAGEGENT_CONFIG.update(management_config) MANAGEMENT_CONFIG.update(management_config)
+2 -1
View File
@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting from database.models import Setting
from ..defaults import DEFAULT_MODES_CONFIG from ..defaults import DEFAULT_MODES_CONFIG
@@ -47,7 +48,7 @@ async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]
else: else:
setting.value = new_values setting.value = new_values
await session.flush() await session.commit()
modes_config = DEFAULT_MODES_CONFIG.copy() modes_config = DEFAULT_MODES_CONFIG.copy()
modes_config.update(new_values) modes_config.update(new_values)
+2 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting from database.models import Setting
from ..defaults import DEFAULT_MONEY_CONFIG from ..defaults import DEFAULT_MONEY_CONFIG
@@ -49,7 +50,7 @@ async def update_money_config(session: AsyncSession, new_values: dict[str, Any])
else: else:
setting.value = new_values setting.value = new_values
await session.flush() await session.commit()
money_config = DEFAULT_MONEY_CONFIG.copy() money_config = DEFAULT_MONEY_CONFIG.copy()
money_config.update(new_values) money_config.update(new_values)
+2 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting from database.models import Setting
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
@@ -49,7 +50,7 @@ async def update_notifications_config(session: AsyncSession, new_values: dict[st
else: else:
setting.value = new_values setting.value = new_values
await session.flush() await session.commit()
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy() notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
notifications_config.update(new_values) notifications_config.update(new_values)
+2 -1
View File
@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting from database.models import Setting
from ..defaults import DEFAULT_PAYMENTS_CONFIG from ..defaults import DEFAULT_PAYMENTS_CONFIG
@@ -47,7 +48,7 @@ async def update_payments_config(session: AsyncSession, new_values: dict[str, bo
else: else:
setting.value = new_values setting.value = new_values
await session.flush() await session.commit()
payments_config = DEFAULT_PAYMENTS_CONFIG.copy() payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
payments_config.update(new_values) payments_config.update(new_values)
+192
View File
@@ -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,
}
+1 -1
View File
@@ -1,8 +1,8 @@
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, User
from core.constants import PAYMENT_SYSTEMS_EXCLUDED from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, User
async def get_hot_leads(session: AsyncSession): async def get_hot_leads(session: AsyncSession):
+104 -18
View File
@@ -17,29 +17,49 @@ async def store_key(
key: str, key: str,
server_id: str, server_id: str,
remnawave_link: str = None, remnawave_link: str = None,
tariff_id: int = None, tariff_id: int | None = None,
alias: str = 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: try:
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)) exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
existing_key = exists.scalar_one_or_none() existing_key = exists.scalar_one_or_none()
if existing_key: if existing_key:
await session.execute( values: dict = {
update(Key) "email": email,
.where(Key.tg_id == tg_id, Key.client_id == client_id) "expiry_time": expiry_time,
.values( "key": key,
email=email, "server_id": server_id,
expiry_time=expiry_time, "remnawave_link": remnawave_link,
key=key, "tariff_id": tariff_id,
server_id=server_id, "alias": alias,
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}") logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else: 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( new_key = Key(
tg_id=tg_id, tg_id=tg_id,
client_id=client_id, client_id=client_id,
@@ -51,6 +71,11 @@ async def store_key(
remnawave_link=remnawave_link, remnawave_link=remnawave_link,
tariff_id=tariff_id, tariff_id=tariff_id,
alias=alias, 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) session.add(new_key)
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}") 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: 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) stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
result = await session.execute(stmt) result = await session.execute(stmt)
row = result.first() row = result.first()
@@ -116,6 +142,11 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
"cluster_name": key.server_id, "cluster_name": key.server_id,
"location_name": key.server_id, "location_name": key.server_id,
"tariff_id": key.tariff_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 is_frozen = TRUE
WHERE tg_id = :tg_id WHERE tg_id = :tg_id
AND client_id = :client_id AND client_id = :client_id
""" """
), ),
{"expiry": time_left, "tg_id": tg_id, "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( await session.execute(
text( text(
""" """
@@ -171,7 +207,7 @@ async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str
is_frozen = FALSE is_frozen = FALSE
WHERE tg_id = :tg_id WHERE tg_id = :tg_id
AND client_id = :client_id AND client_id = :client_id
""" """
), ),
{"expiry": new_expiry_time, "tg_id": tg_id, "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) res = await session.execute(q)
await session.commit() await session.commit()
return res.scalar_one_or_none() is not None 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} сброшены к выбранным")
+18
View File
@@ -77,6 +77,13 @@ class Key(DictLikeMixin, Base):
notified = Column(Boolean, default=False) notified = Column(Boolean, default=False)
notified_24h = 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): class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs" __tablename__ = "tariffs"
@@ -95,6 +102,17 @@ class Tariff(DictLikeMixin, Base):
sort_order = Column(Integer, nullable=True) sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False) 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): class Server(DictLikeMixin, Base):
__tablename__ = "servers" __tablename__ = "servers"
+6
View File
@@ -3,6 +3,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
from core.bootstrap import BUTTONS_CONFIG
from database.models import Referral from database.models import Referral
from logger import logger 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: 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: if CHECK_REFERRAL_REWARD_ISSUED:
bonus_cte = """ bonus_cte = """
WITH RECURSIVE WITH RECURSIVE
+1 -1
View File
@@ -3,8 +3,8 @@ from datetime import date, datetime
from sqlalchemy import and_, exists, func, not_, select from sqlalchemy import and_, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Referral, Tariff, User
from core.constants import PAYMENT_SYSTEMS_EXCLUDED from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, Referral, Tariff, User
async def count_total_users(session: AsyncSession) -> int: async def count_total_users(session: AsyncSession) -> int:
+1 -1
View File
@@ -2,9 +2,9 @@ from sqlalchemy import and_, func, insert, not_, select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Payment, TrackingSource, User from database.models import Payment, TrackingSource, User
from logger import logger 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): async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
+2
View File
@@ -13,6 +13,7 @@ from .payments import router as payments_router
from .profile import router as profile_router from .profile import router as profile_router
from .refferal import router as refferal_router from .refferal import router as refferal_router
from .start import router as start_router from .start import router as start_router
from .tariffs import router as tariff_router
router = Router(name="handlers_main_router") router = Router(name="handlers_main_router")
@@ -29,4 +30,5 @@ router.include_routers(
instructions_router, instructions_router,
admin_router, admin_router,
refferal_router, refferal_router,
tariff_router,
) )
+1 -5
View File
@@ -59,11 +59,7 @@ async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminCluste
group_code = row["group_code"] group_code = row["group_code"]
await session.execute( await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
update(Server)
.where(Server.cluster_name == cluster_name)
.values(tariff_group=group_code)
)
await session.commit() await session.commit()
servers = await get_servers(session=session, include_enabled=True) servers = await get_servers(session=session, include_enabled=True)
+3 -2
View File
@@ -1,7 +1,8 @@
from aiogram.types import InlineKeyboardMarkup from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder 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 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(), 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 "🛠️ Включить тех. работы" maintenance_text = "🛠️ Выключить тех. работы" if maintenance_enabled else "🛠️ Включить тех. работы"
builder.button( builder.button(
text=maintenance_text, text=maintenance_text,
+2 -2
View File
@@ -3,7 +3,7 @@ from aiogram.types import CallbackQuery
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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 database.models import Admin
from filters.admin import IsAdminFilter from filters.admin import IsAdminFilter
@@ -39,7 +39,7 @@ async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession
await callback.answer("❌ Админ не найден.", show_alert=True) await callback.answer("❌ Админ не найден.", show_alert=True)
return return
current_config = dict(MANAGEGENT_CONFIG) current_config = dict(MANAGEMENT_CONFIG)
current_value = bool(current_config.get("MAINTENANCE_ENABLED", False)) current_value = bool(current_config.get("MAINTENANCE_ENABLED", False))
new_value = not current_value new_value = not current_value
current_config["MAINTENANCE_ENABLED"] = new_value current_config["MAINTENANCE_ENABLED"] = new_value
+1
View File
@@ -1,3 +1,4 @@
from .sender_handler import router from .sender_handler import router
__all__ = ["router"] __all__ = ["router"]
+14 -58
View File
@@ -11,8 +11,8 @@ from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
from .sender_states import AdminSender
from .sender_service import BroadcastService from .sender_service import BroadcastService
from .sender_states import AdminSender
from .sender_utils import get_recipients, parse_message_buttons 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"), AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(), IsAdminFilter(),
) )
async def handle_broadcast_type( async def handle_broadcast_type(callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext):
callback_query: CallbackQuery,
callback_data: AdminSenderCallback,
state: FSMContext
):
await callback_query.message.edit_text( await callback_query.message.edit_text(
text=( text=(
"✍️ Введите текст сообщения для рассылки\n\n" "✍️ Введите текст сообщения для рассылки\n\n"
@@ -92,8 +88,7 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
max_len = 1024 if photo else 4096 max_len = 1024 if photo else 4096
if len(clean_text) > max_len: if len(clean_text) > max_len:
await message.answer( await message.answer(
f"⚠️ Сообщение слишком длинное.\n" f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
f"Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
reply_markup=build_admin_back_kb("sender"), reply_markup=build_admin_back_kb("sender"),
) )
await state.clear() await state.clear()
@@ -119,42 +114,21 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
await state.clear() await state.clear()
return return
await state.update_data( await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
text=clean_text,
photo=photo,
keyboard=keyboard.model_dump() if keyboard else None
)
await state.set_state(AdminSender.preview) await state.set_state(AdminSender.preview)
if photo: if photo:
await message.answer_photo( await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
photo=photo,
caption=clean_text,
parse_mode="HTML",
reply_markup=keyboard
)
else: else:
await message.answer( await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
text=clean_text,
parse_mode="HTML",
reply_markup=keyboard
)
await message.answer( await message.answer(
f"👀 Это предпросмотр рассылки.\n" f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: <b>{user_count}</b>\n\nОтправить?",
f"👥 Количество получателей: <b>{user_count}</b>\n\n"
f"Отправить?",
reply_markup=InlineKeyboardMarkup( reply_markup=InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
[ [
InlineKeyboardButton( InlineKeyboardButton(text="📤 Отправить", callback_data="send_broadcast"),
text="📤 Отправить", InlineKeyboardButton(text=" Отмена", callback_data="cancel_broadcast"),
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()) @router.callback_query(F.data == "send_broadcast", IsAdminFilter())
async def handle_broadcast_confirm( async def handle_broadcast_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession
):
data = await state.get_data() data = await state.get_data()
text_message = data.get("text") text_message = data.get("text")
photo = data.get("photo") photo = data.get("photo")
@@ -200,35 +170,21 @@ async def handle_broadcast_confirm(
await state.clear() await state.clear()
return return
await callback_query.message.edit_text( await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
f"📤 <b>Рассылка начата!</b>\n"
f"👥 Количество получателей: {total_users}"
)
messages = [] messages = []
for tg_id in tg_ids: for tg_id in tg_ids:
message_data = { message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
"tg_id": tg_id,
"text": text_message,
"photo": photo,
"keyboard": keyboard
}
messages.append(message_data) messages.append(message_data)
broadcast_service = BroadcastService( broadcast_service = BroadcastService(bot=callback_query.bot, session=session, messages_per_second=35)
bot=callback_query.bot,
session=session,
messages_per_second=35
)
stats = await broadcast_service.broadcast(messages, workers=5) stats = await broadcast_service.broadcast(messages, workers=5)
duration_minutes = int(stats["total_duration"] // 60) duration_minutes = int(stats["total_duration"] // 60)
duration_seconds = int(stats["total_duration"] % 60) duration_seconds = int(stats["total_duration"] % 60)
duration_str = ( duration_str = (
f"{duration_minutes} мин {duration_seconds} сек" f"{duration_minutes} мин {duration_seconds} сек" if duration_minutes > 0 else f"{duration_seconds} сек"
if duration_minutes > 0
else f"{duration_seconds} сек"
) )
await callback_query.message.answer( await callback_query.message.answer(
+12 -23
View File
@@ -12,7 +12,7 @@ from logger import logger
class BroadcastMessage: 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.tg_id = tg_id
self.text = text self.text = text
self.photo = photo self.photo = photo
@@ -22,7 +22,7 @@ class BroadcastMessage:
class RateLimiter: 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.max_rate = max_rate
self.window = window self.window = window
self.send_times = deque() self.send_times = deque()
@@ -52,8 +52,7 @@ class RateLimiter:
class BroadcastService: class BroadcastService:
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None:
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35):
self.bot = bot self.bot = bot
self.session = session self.session = session
self.rate_limiter = RateLimiter(max_rate=messages_per_second) self.rate_limiter = RateLimiter(max_rate=messages_per_second)
@@ -71,18 +70,11 @@ class BroadcastService:
if msg.photo: if msg.photo:
await self.bot.send_photo( await self.bot.send_photo(
chat_id=msg.tg_id, chat_id=msg.tg_id, photo=msg.photo, caption=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
photo=msg.photo,
caption=msg.text,
parse_mode="HTML",
reply_markup=msg.keyboard
) )
else: else:
await self.bot.send_message( await self.bot.send_message(
chat_id=msg.tg_id, chat_id=msg.tg_id, text=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
text=msg.text,
parse_mode="HTML",
reply_markup=msg.keyboard
) )
return True return True
@@ -91,8 +83,7 @@ class BroadcastService:
msg.retry_after = e.retry_after msg.retry_after = e.retry_after
msg.attempts += 1 msg.attempts += 1
logger.warning( logger.warning(
f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. " f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. (попытка {msg.attempts})"
f"(попытка {msg.attempts})"
) )
await self.delayed_queue.put(msg) await self.delayed_queue.put(msg)
return False return False
@@ -133,7 +124,7 @@ class BroadcastService:
else: else:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
except asyncio.TimeoutError: except TimeoutError:
continue continue
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка в обработчике отложенных сообщений: {e}") logger.error(f"❌ Ошибка в обработчике отложенных сообщений: {e}")
@@ -154,7 +145,7 @@ class BroadcastService:
self.queue.task_done() self.queue.task_done()
except asyncio.TimeoutError: except TimeoutError:
continue continue
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка в воркере рассылки: {e}") logger.error(f"❌ Ошибка в воркере рассылки: {e}")
@@ -166,12 +157,11 @@ class BroadcastService:
try: try:
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from database.models import BlockedUser from database.models import BlockedUser
values = [{"tg_id": tg_id} for tg_id in self.blocked_users] values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing( stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
index_elements=[BlockedUser.tg_id]
)
await self.session.execute(stmt) await self.session.execute(stmt)
await self.session.commit() await self.session.commit()
logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users") logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users")
@@ -191,7 +181,7 @@ class BroadcastService:
tg_id=msg_data["tg_id"], tg_id=msg_data["tg_id"],
text=msg_data["text"], text=msg_data["text"],
photo=msg_data.get("photo"), photo=msg_data.get("photo"),
keyboard=msg_data.get("keyboard") keyboard=msg_data.get("keyboard"),
) )
await self.queue.put(msg) await self.queue.put(msg)
@@ -229,7 +219,7 @@ class BroadcastService:
"failed_count": len(self.results) - success_count, "failed_count": len(self.results) - success_count,
"avg_speed": avg_speed, "avg_speed": avg_speed,
"total_messages": len(messages), "total_messages": len(messages),
"blocked_users": len(self.blocked_users) "blocked_users": len(self.blocked_users),
} }
logger.info( logger.info(
@@ -238,4 +228,3 @@ class BroadcastService:
) )
return stats return stats
-1
View File
@@ -4,4 +4,3 @@ from aiogram.fsm.state import State, StatesGroup
class AdminSender(StatesGroup): class AdminSender(StatesGroup):
waiting_for_message = State() waiting_for_message = State()
preview = State() preview = State()
+10 -20
View File
@@ -7,31 +7,22 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import distinct, exists, func, not_, select from sqlalchemy import distinct, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession 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 database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
from logger import logger from logger import logger
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
async def get_recipients( async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str | None = None) -> tuple[list[int], int]:
session: AsyncSession,
send_to: str,
cluster_name: str | None = None
) -> tuple[list[int], int]:
now_ms = int(datetime.utcnow().timestamp() * 1000) now_ms = int(datetime.utcnow().timestamp() * 1000)
banned_tg_ids = select(BlockedUser.tg_id).union_all( banned_tg_ids = select(BlockedUser.tg_id).union_all(
select(ManualBan.tg_id).where( select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow())
)
) )
query = None query = None
if send_to == "subscribed": if send_to == "subscribed":
query = ( query = (
select(distinct(User.tg_id)) select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
.join(Key)
.where(Key.expiry_time > now_ms)
.where(~User.tg_id.in_(banned_tg_ids))
) )
elif send_to == "unsubscribed": elif send_to == "unsubscribed":
@@ -96,22 +87,22 @@ async def get_recipients(
def strip_html_tags(text: str) -> str: def strip_html_tags(text: str) -> str:
text = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", text) text = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", text)
text = re.sub(r'<[^>]+>', '', text) text = re.sub(r"<[^>]+>", "", text)
text = text.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&') text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
return text.strip() return text.strip()
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]: def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
buttons_match = re.search(r'(<[^>]+>)?\s*BUTTONS\s*:\s*(</[^>]+>)?', text, re.IGNORECASE) buttons_match = re.search(r"(<[^>]+>)?\s*BUTTONS\s*:\s*(</[^>]+>)?", text, re.IGNORECASE)
if not buttons_match: if not buttons_match:
return text, None return text, None
clean_text = text[:buttons_match.start()].strip() clean_text = text[: buttons_match.start()].strip()
buttons_section = text[buttons_match.start():].strip() buttons_section = text[buttons_match.start() :].strip()
buttons_text = strip_html_tags(buttons_section) buttons_text = strip_html_tags(buttons_section)
buttons_text = re.sub(r'^.*?BUTTONS\s*:\s*', '', buttons_text, flags=re.IGNORECASE).strip() buttons_text = re.sub(r"^.*?BUTTONS\s*:\s*", "", buttons_text, flags=re.IGNORECASE).strip()
if not buttons_text: if not buttons_text:
return clean_text, None return clean_text, None
@@ -156,4 +147,3 @@ def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
return clean_text, keyboard return clean_text, keyboard
+2
View File
@@ -6,6 +6,7 @@ from .settings_manage import router as settings_manage_router
from .settings_modes import router as settings_modes_router from .settings_modes import router as settings_modes_router
from .settings_money import router as settings_panels_router from .settings_money import router as settings_panels_router
from .settings_notifications import router as settings_notifications_router from .settings_notifications import router as settings_notifications_router
from .settings_tariffs import router as settings_tariffs_router
router = Router(name="admin_settings") router = Router(name="admin_settings")
@@ -15,3 +16,4 @@ router.include_router(settings_cashboxes_router)
router.include_router(settings_panels_router) router.include_router(settings_panels_router)
router.include_router(settings_notifications_router) router.include_router(settings_notifications_router)
router.include_router(settings_modes_router) router.include_router(settings_modes_router)
router.include_router(settings_tariffs_router)
+8 -4
View File
@@ -4,11 +4,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from .settings_config import ( from .settings_config import (
BUTTON_TITLES, BUTTON_TITLES,
NOTIFICATION_TITLES,
NOTIFICATION_TIME_FIELDS,
PAYMENT_PROVIDER_TITLES,
MODES_TITLES, MODES_TITLES,
MONEY_FIELDS, MONEY_FIELDS,
NOTIFICATION_TIME_FIELDS,
NOTIFICATION_TITLES,
PAYMENT_PROVIDER_TITLES,
) )
@@ -73,8 +73,12 @@ def build_settings_kb() -> InlineKeyboardMarkup:
text="Режимы", text="Режимы",
callback_data=AdminPanelCallback(action="settings_modes").pack(), callback_data=AdminPanelCallback(action="settings_modes").pack(),
) )
builder.button(
text="Тарификация",
callback_data=AdminPanelCallback(action="settings_tariffs").pack(),
)
builder.adjust(2, 2, 1) builder.adjust(2, 2, 2)
builder.row(build_admin_back_btn()) builder.row(build_admin_back_btn())
return builder.as_markup() return builder.as_markup()
@@ -1,5 +1,6 @@
from typing import Final from typing import Final
BUTTON_TITLES: Final[dict[str, str]] = { BUTTON_TITLES: Final[dict[str, str]] = {
"CHANNEL_BUTTON_ENABLE": "Канал", "CHANNEL_BUTTON_ENABLE": "Канал",
"DONATIONS_BUTTON_ENABLE": "Донаты", "DONATIONS_BUTTON_ENABLE": "Донаты",
@@ -13,6 +14,7 @@ BUTTON_TITLES: Final[dict[str, str]] = {
"TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов", "TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов",
"QRCODE_BUTTON_ENABLE": "QR подписки", "QRCODE_BUTTON_ENABLE": "QR подписки",
"HWID_RESET_BUTTON_ENABLE": "Сброс HWID", "HWID_RESET_BUTTON_ENABLE": "Сброс HWID",
"ANDROID_TV_BUTTON_ENABLE": "Android TV",
} }
NOTIFICATION_TITLES: Final[dict[str, str]] = { NOTIFICATION_TITLES: Final[dict[str, str]] = {
+216
View File
@@ -0,0 +1,216 @@
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from ..panel.keyboard import AdminPanelCallback
router = Router()
PACK_MODES = ["", "traffic", "devices", "all"]
def format_pack_mode_label(mode: str | None) -> str:
"""Возвращает человекочитаемое название режима пакетов."""
if not mode:
return "выкл"
if mode == "traffic":
return "только трафик"
if mode == "devices":
return "только устройства"
if mode == "all":
return "трафик и устройства"
return f"неизвестно ({mode})"
def build_tariffs_settings_kb() -> InlineKeyboardMarkup:
"""Клавиатура основного экрана настроек тарификации."""
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
pack_mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=f"Понижение: {'вкл' if allow_downgrade else 'выкл'}",
callback_data=AdminPanelCallback(action="settings_tariffs_toggle_downgrade").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=f"Режим пакетов: {format_pack_mode_label(pack_mode)}",
callback_data=AdminPanelCallback(action="settings_tariffs_packs").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="Назад",
callback_data=AdminPanelCallback(action="settings").pack(),
)
)
return builder.as_markup()
def build_tariffs_settings_text() -> str:
"""Текст основного экрана настроек тарификации."""
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
pack_mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
lines: list[str] = [
"⚙️ Настройки тарификации",
"",
f"• Понижение условий при изменении: {'включено' if allow_downgrade else 'выключено'}",
f"• Режим доплат пакетами: {format_pack_mode_label(pack_mode)}",
"",
"Нажмите «Режим пакетов», чтобы подробнее настроить доплаты к активной подписке.",
]
return "\n".join(lines)
async def refresh_tariffs_settings_screen(callback: CallbackQuery) -> None:
"""Обновляет основной экран настроек тарификации."""
await callback.message.edit_text(
build_tariffs_settings_text(),
reply_markup=build_tariffs_settings_kb(),
)
await callback.answer()
def build_tariffs_packs_kb() -> InlineKeyboardMarkup:
"""Клавиатура экрана выбора режима пакетов."""
current = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=f"{'' if current == '' else ''}Выкл",
callback_data=AdminPanelCallback(action="settings_tariffs_mode_off").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=f"{'' if current == 'traffic' else ''}Только трафик",
callback_data=AdminPanelCallback(action="settings_tariffs_mode_traffic").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=f"{'' if current == 'devices' else ''}Только устройства",
callback_data=AdminPanelCallback(action="settings_tariffs_mode_devices").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=f"{'' if current == 'all' else ''}Трафик и устройства",
callback_data=AdminPanelCallback(action="settings_tariffs_mode_all").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminPanelCallback(action="settings_tariffs").pack(),
)
)
return builder.as_markup()
def build_tariffs_packs_text() -> str:
"""Текст экрана выбора режима пакетов."""
current = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
lines: list[str] = [
"📦 Режим доплат пакетами",
"",
"При активной подписке можно продавать не новый тариф, а доплаты к текущим лимитам.",
"",
"Режимы:",
"• Выкл — доплаты пакетами отключены, работает обычный конфигуратор лимитов (клиент меняет конфигурацию тарифа для продления).",
"• Только трафик — пользователь докупает ГБ до продления, количество устройств не меняется.",
"• Только устройства — пользователь докупает устройства до продления, лимит трафика не меняется.",
"• Трафик и устройства — можно одновременно докупать и трафик, и устройства до продления как единый пакет.",
"",
f"Текущий режим: {format_pack_mode_label(current)}",
"",
"Выберите режим, который будет использоваться при доплате за расширение уже существующей подписки.",
]
return "\n".join(lines)
async def refresh_tariffs_packs_screen(callback: CallbackQuery) -> None:
"""Обновляет экран выбора режима пакетов."""
await callback.message.edit_text(
build_tariffs_packs_text(),
reply_markup=build_tariffs_packs_kb(),
)
await callback.answer()
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_tariffs"))
async def open_tariffs_settings(callback: CallbackQuery, session: AsyncSession) -> None:
"""Открывает основной экран настроек тарификации."""
await refresh_tariffs_settings_screen(callback)
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_tariffs_toggle_downgrade"))
async def toggle_tariffs_downgrade(callback: CallbackQuery, session: AsyncSession) -> None:
"""Переключает флаг понижения условий."""
current = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
new_config: dict[str, Any] = dict(TARIFFS_CONFIG)
new_config["ALLOW_DOWNGRADE"] = not current
await update_tariffs_config(session, new_config)
await refresh_tariffs_settings_screen(callback)
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_tariffs_packs"))
async def open_tariffs_packs(callback: CallbackQuery, session: AsyncSession) -> None:
"""Открывает экран выбора режима пакетов."""
await refresh_tariffs_packs_screen(callback)
@router.callback_query(
AdminPanelCallback.filter(
F.action.in_([
"settings_tariffs_mode_off",
"settings_tariffs_mode_traffic",
"settings_tariffs_mode_devices",
"settings_tariffs_mode_all",
])
)
)
async def set_tariffs_pack_mode(
callback: CallbackQuery,
callback_data: AdminPanelCallback,
session: AsyncSession,
) -> None:
"""Сохраняет выбранный режим пакетов и обновляет экран."""
action = callback_data.action
if action == "settings_tariffs_mode_off":
new_mode = ""
elif action == "settings_tariffs_mode_traffic":
new_mode = "traffic"
elif action == "settings_tariffs_mode_devices":
new_mode = "devices"
elif action == "settings_tariffs_mode_all":
new_mode = "all"
else:
new_mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
new_config: dict[str, Any] = dict(TARIFFS_CONFIG)
new_config["KEY_ADDONS_PACK_MODE"] = new_mode
await update_tariffs_config(session, new_config)
await refresh_tariffs_packs_screen(callback)
+5 -4
View File
@@ -3,8 +3,9 @@ from aiogram import Router
router = Router() router = Router()
from . import ( from . import ( # noqa: F401
tariff_manage, # noqa: F401 tariff_configurator,
tariff_sorting, # noqa: F401 tariff_manage,
tariff_subgroups, # noqa: F401 tariff_sorting,
tariff_subgroups,
) )
+23 -1
View File
@@ -204,9 +204,31 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
return builder.as_markup() return builder.as_markup()
def build_single_tariff_kb(tariff_id: int, group_code: str = None) -> InlineKeyboardMarkup: def build_single_tariff_kb(
tariff_id: int,
group_code: str | None = None,
configurable: bool | None = None,
) -> InlineKeyboardMarkup:
configurator_title = "⚙️ Конфигуратор"
if configurable is True:
configurator_title += ""
elif configurable is False:
configurator_title += ""
return InlineKeyboardMarkup( return InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
[
InlineKeyboardButton(
text=configurator_title,
callback_data=f"toggle_configurable|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="🔧 Настройки конфигуратора",
callback_data=f"edit_config|{tariff_id}",
)
],
[ [
InlineKeyboardButton( InlineKeyboardButton(
text="✏️ Редактировать", text="✏️ Редактировать",
@@ -0,0 +1,6 @@
from . import ( # noqa: F401
common,
device_pricing,
options,
traffic_pricing,
)
@@ -0,0 +1,387 @@
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.settings.tariffs_config import normalize_tariff_config
from database.models import Tariff
from filters.admin import IsAdminFilter
from .. import router
from ..keyboard import AdminTariffCallback
class TariffConfigState(StatesGroup):
choosing_section = State()
entering_devices = State()
entering_traffic = State()
entering_device_step = State()
entering_device_overrides = State()
entering_traffic_step = State()
entering_traffic_overrides = State()
def build_config_menu_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="📱 Варианты устройств",
callback_data=f"cfg_edit_devices|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="📦 Варианты трафика",
callback_data=f"cfg_edit_traffic|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="💰 Шаг доплаты за устройства",
callback_data=f"cfg_edit_device_step|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="📊 Доплаты по вариантам устройств",
callback_data=f"cfg_edit_device_over|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="💰 Шаг доплаты за трафик (ГБ)",
callback_data=f"cfg_edit_traffic_step|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="📊 Доплаты по вариантам трафика",
callback_data=f"cfg_edit_traffic_over|{tariff_id}",
)
],
[
InlineKeyboardButton(
text="⬅️ Назад к тарифу",
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack(),
)
],
]
)
def build_cancel_config_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"edit_config|{tariff_id}",
)
]
]
)
def calculate_device_formula_extra(tariff: Tariff, devices: int) -> int:
base_devices = tariff.device_limit
step = getattr(tariff, "device_step_rub", None) or 0
if base_devices is None or devices <= base_devices:
return 0
return (devices - base_devices) * step
def calculate_traffic_formula_extra(tariff: Tariff, gb_value: int) -> int:
base_traffic = tariff.traffic_limit
step = getattr(tariff, "traffic_step_rub", None) or 0
if gb_value == 0:
return 0
if base_traffic is None or gb_value <= base_traffic:
return 0
return (gb_value - base_traffic) * step
def build_device_overrides_screen(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
tariff_id = tariff.id
base_price = int(tariff.price_rub or 0)
device_options = tariff.device_options or []
overrides = getattr(tariff, "device_overrides", None) or {}
lines: list[str] = []
lines.append("📊 Доплаты по вариантам устройств.")
lines.append("")
lines.append(f"Базовая цена тарифа: <b>{base_price}₽</b>")
lines.append("Ниже показаны варианты устройств и текущая доплата.")
lines.append("Значение <code>0</code> можно использовать как безлимит по устройствам.")
lines.append("Нажмите на вариант, чтобы задать доплату в рублях.")
lines.append("Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу.")
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"Базовая цена тарифа: <b>{base_price}₽</b>")
lines.append("Ниже показаны варианты лимитов и текущая доплата.")
lines.append("Значение <code>0</code> — безлимитный трафик.")
lines.append("Нажмите на вариант, чтобы задать доплату в рублях.")
lines.append("Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу.")
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 = (
"<blockquote>"
"🎯 База тарифа:\n"
f"• Длительность: <b>{base_duration} дней</b>\n"
f"• Устройства: <b>{base_devices}</b>\n"
f"• Трафик: <b>{base_traffic_text}</b>\n"
f"• Цена: <b>{base_price}₽</b>\n"
"</blockquote>\n"
)
config_block = f"<blockquote>\n{duration_line}\n{devices_line}\n{traffic_line}\n</blockquote>\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"<blockquote>{device_step_line}\n{device_over_line}\n</blockquote>\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"<blockquote>\n{traffic_step_line}\n{traffic_over_line}\n</blockquote>"
return (
f"<b>⚙️ Конфигуратор тарифа: {tariff.name}</b>\n\n"
f"Статус: <b>{configurable_text}</b>\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))
@@ -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"
"Например: <code>50</code>\n\n"
"Чтобы выключить автоматическую доплату за устройства, отправьте <code>0</code>."
)
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"Текущая доплата для этого варианта: <b>{effective_extra}₽</b> ({note}).\n\n"
"Введите новую <u>доплату за устройства</u> для этого варианта в рублях.\n"
"Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу."
)
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)
@@ -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"
"Например: <code>1 3 5</code>\n\n"
"Число <code>0</code> можно использовать как вариант безлимита.\n"
"Чтобы совсем отключить выбор устройств и использовать только базовый лимит тарифа, отправьте единичный <code>0</code>."
)
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"
"например: <code>1 3 5</code>, <code>0 1 3 5</code> (0 как безлимит)\n"
"или <code>0</code> для отключения выбора устройств.",
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"
"Например: <code>100 200 500</code>\n\n"
"Число <code>0</code> можно использовать как вариант безлимита.\n"
"Чтобы совсем отключить выбор трафика и использовать только базовый лимит тарифа, отправьте единичный <code>0</code>."
)
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"
"например: <code>100 200 500</code>.\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))
@@ -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"
"Например: <code>5</code>\n\n"
"Чтобы выключить автоматическую доплату за трафик, отправьте <code>0</code>."
)
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"Текущая доплата для этого варианта: <b>{effective_extra}₽</b> ({note}).\n\n"
"Введите новую <u>доплату за трафик</u> для этого лимита в рублях.\n"
"Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу."
)
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)
+21
View File
@@ -587,3 +587,24 @@ async def start_tariff_creation_existing_group(
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:", f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
reply_markup=build_cancel_kb(), 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)
+4 -1
View File
@@ -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 "Безлимит" device_text = f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
sort_order = getattr(tariff, "sort_order", 1) sort_order = getattr(tariff, "sort_order", 1)
vless_text = "Да" if getattr(tariff, "vless", False) else "Нет" vless_text = "Да" if getattr(tariff, "vless", False) else "Нет"
configurable = bool(getattr(tariff, "configurable", False))
configurable_text = "Включен" if configurable else "Выключен"
text = ( text = (
f"<b>📄 Тариф: {tariff.name}</b>\n\n" f"<b>📄 Тариф: {tariff.name}</b>\n\n"
@@ -48,8 +50,9 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
f"📦 Трафик: <b>{traffic_text}</b>\n" f"📦 Трафик: <b>{traffic_text}</b>\n"
f"📱 Устройств: <b>{device_text}</b>\n" f"📱 Устройств: <b>{device_text}</b>\n"
f"🔗 VLESS: <b>{vless_text}</b>\n" f"🔗 VLESS: <b>{vless_text}</b>\n"
f"⚙️ Конфигуратор: <b>{configurable_text}</b>\n"
f"🔢 Позиция: <b>{sort_order}</b>\n" f"🔢 Позиция: <b>{sort_order}</b>\n"
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}" 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)
+1 -1
View File
@@ -1,6 +1,6 @@
from aiogram import Router 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() router = Router()
+4 -4
View File
@@ -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: def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() 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( builder.button(
text="⏳ Время истечения", text="⏳ Время истечения",
@@ -287,9 +289,7 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
else: else:
builder.button( builder.button(
text=FREEZE, text=FREEZE,
callback_data=AdminUserEditorCallback( callback_data=AdminUserEditorCallback(action="users_freeze", data=email, tg_id=key_details["tg_id"]).pack(),
action="users_freeze", data=email, tg_id=key_details["tg_id"]
).pack(),
) )
if HWID_RESET_BUTTON: if HWID_RESET_BUTTON:
+4 -16
View File
@@ -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") date_str = created_at.strftime("%Y-%m-%d %H:%M:%S")
sign = "+" if amount > 0 else "-" if amount < 0 else "" sign = "+" if amount > 0 else "-" if amount < 0 else ""
abs_amount = abs(amount) abs_amount = abs(amount)
return ( return f"\n<blockquote>Админ {sign}{abs_amount}Р\n⏳ Дата: {date_str}</blockquote>"
f"\n<blockquote>Админ {sign}{abs_amount}Р"
f"\n⏳ Дата: {date_str}</blockquote>"
)
def format_user_payment(amount: float, created_at: datetime, payment_system: str, status: str) -> 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) abs_amount = abs(amount)
system_name = payment_system or "Неизвестно" system_name = payment_system or "Неизвестно"
return ( return (
f"\n<blockquote>💸 Сумма: {abs_amount} | {system_name}" f"\n<blockquote>💸 Сумма: {abs_amount} | {system_name}\n📌 Статус: {status}\n⏳ Дата: {date_str}</blockquote>"
f"\n📌 Статус: {status}"
f"\n⏳ Дата: {date_str}</blockquote>"
) )
@@ -68,21 +63,14 @@ async def handle_balance_change(
stmt_user = ( stmt_user = (
select(Payment.amount, Payment.created_at, Payment.payment_system, Payment.status) select(Payment.amount, Payment.created_at, Payment.payment_system, Payment.status)
.where( .where(Payment.tg_id == tg_id, Payment.payment_system != "admin")
Payment.tg_id == tg_id,
Payment.payment_system != "admin"
)
.order_by(Payment.created_at.desc()) .order_by(Payment.created_at.desc())
.limit(5) .limit(5)
) )
result_user = await session.execute(stmt_user) result_user = await session.execute(stmt_user)
user_records = result_user.all() user_records = result_user.all()
text = ( text = f"<b>💵 Изменение баланса</b>\n\n🆔 ID: <b>{tg_id}</b>\n💰 Баланс: <b>{balance}Р</b>"
f"<b>💵 Изменение баланса</b>"
f"\n\n🆔 ID: <b>{tg_id}</b>"
f"\n💰 Баланс: <b>{balance}Р</b>"
)
text += "\n\n<b>📊 Операции админа (5):</b>" text += "\n\n<b>📊 Операции админа (5):</b>"
if admin_records: if admin_records:
+2 -5
View File
@@ -41,7 +41,6 @@ from handlers.utils import generate_random_email, handle_error
from hooks.hook_buttons import insert_hook_buttons from hooks.hook_buttons import insert_hook_buttons
from hooks.processors import process_admin_key_edit_menu from hooks.processors import process_admin_key_edit_menu
from logger import logger from logger import logger
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
from .keyboard import ( 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) result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session)
if result["status"] != "success": if result["status"] != "success":
text_error = ( text_error = (
"Произошла ошибка при заморозке подписки.\n" f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
f"Детали: {result.get('error') or result.get('results')}"
) )
await callback_query.message.edit_text( await callback_query.message.edit_text(
text_error, 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) result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True, session=session)
if result["status"] != "success": if result["status"] != "success":
text_error = ( text_error = (
"Произошла ошибка при включении подписки.\n" f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
f"Детали: {result.get('error') or result.get('results')}"
) )
await callback_query.message.edit_text( await callback_query.message.edit_text(
text_error, text_error,
+10
View File
@@ -21,6 +21,11 @@ TRIAL_SUB = "🎁 Пробная подписка"
MY_SUB = "🔐 Моя подписка" MY_SUB = "🔐 Моя подписка"
RENEW_SUB = "🔄 Обновить подписку" RENEW_SUB = "🔄 Обновить подписку"
ADDONS_BUTTON_DEVICES = "➕ Добавить устройства"
ADDONS_BUTTON_TRAFFIC = " Добавить трафик"
ADDONS_BUTTON_DEVICES_TRAFFIC = "➕ Добавить устройства/трафик"
# Меню Оплат и баланса # Меню Оплат и баланса
BALANCE_HISTORY = "📊 История пополнения" BALANCE_HISTORY = "📊 История пополнения"
@@ -86,6 +91,11 @@ TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку" HWID_BUTTON = "♻️ Сбросить привязку"
ROUTER_BUTTON = "Подключить роутер" ROUTER_BUTTON = "Подключить роутер"
# Кнопки конфигуратора
DOWNGRADE_ADDON_BUTTON_TEXT = "Понизить условия"
DOWNGRADE_CONFIRM_BUTTON_TEXT = "Подтвердить понижение"
CONFIG_PAY_BUTTON_TEXT = "Оплатить {amount}"
CONFIRM_ADDON_BUTTON_TEXT = "Подтвердить доплату {amount}"
# Кнопки касс # Кнопки касс
+4 -4
View File
@@ -7,18 +7,18 @@ from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from core.bootstrap import MODES_CONFIG
from config import ( from config import (
CONNECT_MACOS, CONNECT_MACOS,
CONNECT_WINDOWS, CONNECT_WINDOWS,
DOWNLOAD_MACOS, DOWNLOAD_MACOS,
DOWNLOAD_PC, DOWNLOAD_PC,
HAPP_CRYPTOLINK,
REMNAWAVE_WEBAPP,
SUPPORT_CHAT_URL, SUPPORT_CHAT_URL,
WEBHOOK_HOST, 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 ( from handlers.buttons import (
BACK, BACK,
CONNECT_MACOS_BUTTON, CONNECT_MACOS_BUTTON,
@@ -1,6 +1,5 @@
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from math import ceil
from typing import Any from typing import Any
import pytz import pytz
@@ -10,20 +9,18 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import ( from config import (
DISCOUNT_ACTIVE_HOURS, DISCOUNT_ACTIVE_HOURS,
NOTIFY_EXTRA_DAYS, NOTIFY_EXTRA_DAYS,
TRIAL_TIME_DISABLE, TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION, USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
) )
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
from database import ( from database import (
add_user, add_user,
check_user_exists, check_user_exists,
get_balance,
get_tariff_by_id,
get_tariffs_for_cluster, get_tariffs_for_cluster,
get_trial, get_trial,
) )
@@ -31,32 +28,28 @@ from database.models import Admin
from database.notifications import check_hot_lead_discount from database.notifications import check_hot_lead_discount
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.admin.panel.keyboard import AdminPanelCallback 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.currency_rates import format_for_user
from handlers.payments.fast_payment_flow import try_fast_payment_flow
from handlers.texts import ( from handlers.texts import (
CREATING_CONNECTION_MSG, CREATING_CONNECTION_MSG,
DISCOUNT_OFFER_MESSAGE, DISCOUNT_OFFER_MESSAGE,
DISCOUNT_OFFER_STEP2, DISCOUNT_OFFER_STEP2,
DISCOUNT_OFFER_STEP3, DISCOUNT_OFFER_STEP3,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG, SELECT_TARIFF_PLAN_MSG,
) )
from handlers.utils import edit_or_send_message, format_discount_time_left, get_least_loaded_cluster 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.hook_buttons import insert_hook_buttons
from hooks.processors import ( from hooks.processors import (
process_check_discount_validity,
process_purchase_tariff_group_override, process_purchase_tariff_group_override,
process_tariff_menu, process_tariff_menu,
) )
from logger import logger from logger import logger
from .key_cluster_mode import key_cluster_mode from .key_mode.key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode from .key_mode.key_country_mode import key_country_mode
router = Router() router = Router()
moscow_tz = pytz.timezone("Europe/Moscow") 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 == "create_key")
@router.callback_query(F.data == "buy") @router.callback_query(F.data == "buy")
@router.message(F.text == "/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): if isinstance(callback_query_or_message, CallbackQuery):
tg_id = callback_query_or_message.message.chat.id tg_id = callback_query_or_message.from_user.id
message_or_query = callback_query_or_message message_or_query: Message | CallbackQuery = callback_query_or_message
else: 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 message_or_query = callback_query_or_message
await handle_key_creation(tg_id, state, session, message_or_query) 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( async def handle_key_creation(
tg_id: int, tg_id: int,
state: FSMContext, state: FSMContext,
session: Any, session: AsyncSession,
message_or_query: Message | CallbackQuery, message_or_query: Message | CallbackQuery,
): ):
state_data = await state.get_data() state_data = await state.get_data()
@@ -91,6 +88,9 @@ async def handle_key_creation(
await state.update_data(key_creation_in_progress=True) 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: try:
current_time = datetime.now(moscow_tz) current_time = datetime.now(moscow_tz)
@@ -100,16 +100,12 @@ async def handle_key_creation(
if trial_status in [0, -1]: if trial_status in [0, -1]:
trial_tariffs = await get_tariffs(session, group_code="trial") trial_tariffs = await get_tariffs(session, group_code="trial")
if not trial_tariffs: if not trial_tariffs:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message( await edit_or_send_message(
target_message=( target_message=target_message,
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
),
text="❌ Пробная подписка временно недоступна.", text="❌ Пробная подписка временно недоступна.",
reply_markup=InlineKeyboardBuilder() reply_markup=builder.as_markup(),
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
.as_markup(),
) )
return return
@@ -123,42 +119,40 @@ async def handle_key_creation(
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}") logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
await edit_or_send_message( await edit_or_send_message(
target_message=( target_message=target_message,
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=CREATING_CONNECTION_MSG, text=CREATING_CONNECTION_MSG,
reply_markup=None, reply_markup=None,
) )
await state.update_data(is_trial=True, plan=trial_tariff["id"]) 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 return
try: try:
cluster_name = await get_least_loaded_cluster(session) cluster_name = await get_least_loaded_cluster(session)
except ValueError as e: except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}") logger.error(f"Нет доступных кластеров: {e}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message( await edit_or_send_message(
target_message=( target_message=target_message,
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=str(e), text=str(e),
reply_markup=InlineKeyboardBuilder() reply_markup=builder.as_markup(),
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
.as_markup(),
) )
return return
tariffs = await get_tariffs_for_cluster(session, cluster_name) tariffs = await get_tariffs_for_cluster(session, cluster_name)
language_code = ( discount_info: dict[str, Any] | None = None
message_or_query.from_user.language_code subgroup_weights: dict[str, int] = {}
if not isinstance(message_or_query, CallbackQuery)
else message_or_query.from_user.language_code
)
discount_info = None
subgroup_weights = {}
if tariffs: if tariffs:
group_code = tariffs[0].get("group_code") group_code = tariffs[0].get("group_code")
@@ -183,7 +177,11 @@ async def handle_key_creation(
if override_result.get("discount_info"): if override_result.get("discount_info"):
await state.update_data(discount_info=override_result["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")] tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"] subgroup_weights = tariffs_data["subgroup_weights"]
@@ -195,17 +193,18 @@ async def handle_key_creation(
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack() text="🔗 Привязать тариф",
callback_data=AdminPanelCallback(action="clusters").pack(),
) )
) )
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = ( text = (
f"🚫 <b>Невозможно создать подписку</b>\n\n" "🚫 <b>Невозможно создать подписку</b>\n\n"
f"📊 <b>Информация о кластере:</b>\n<blockquote>" "📊 <b>Информация о кластере:</b>\n<blockquote>"
f"🌐 <b>Кластер:</b> <code>{cluster_name}</code>\n" f"🌐 <b>Кластер:</b> <code>{cluster_name}</code>\n"
f"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>" "⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
f"💡 <b>Привяжите тариф к кластеру</b>" "💡 <b>Привяжите тариф к кластеру</b>"
) )
else: else:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -213,9 +212,7 @@ async def handle_key_creation(
text = "❌ Нет доступных тарифов для выбора." text = "❌ Нет доступных тарифов для выбора."
await edit_or_send_message( await edit_or_send_message(
target_message=( target_message=target_message,
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=text, text=text,
reply_markup=builder.as_markup(), 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 group_code = tariffs[0].get("group_code") if tariffs else None
if not group_code: if not group_code:
await edit_or_send_message( await edit_or_send_message(
target_message=( target_message=target_message,
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text="❌ Не удалось определить группу тарифов.", text="❌ Не удалось определить группу тарифов.",
reply_markup=None, reply_markup=None,
) )
return return
grouped_tariffs = defaultdict(list) grouped_tariffs: dict[str | None, list[dict[str, Any]]] = defaultdict(list)
for t in tariffs: for tariff in tariffs:
subgroup = t.get("subgroup_title") subgroup = tariff.get("subgroup_title")
grouped_tariffs[subgroup].append(t) grouped_tariffs[subgroup].append(tariff)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []): for tariff in grouped_tariffs.get(None, []):
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code) price_text = await format_for_user(session, tg_id, tariff.get("price_rub", 0), language_code)
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=f"{t['name']}{price_txt}", text=f"{tariff['name']}{price_text}",
callback_data=f"select_tariff_plan|{t['id']}", callback_data=f"select_tariff_plan|{tariff['id']}",
) )
) )
sorted_subgroups = sorted( sorted_subgroups = sorted(
[k for k in grouped_tariffs if k], [key for key in grouped_tariffs if key],
key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x), key=lambda title: (subgroup_weights.get(title, 999999) if subgroup_weights else 999999, title),
) )
for subgroup in sorted_subgroups: for subgroup in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup, group_code) subgroup_hash = create_subgroup_hash(subgroup, group_code)
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=f"{subgroup}", text=subgroup,
callback_data=f"tariff_subgroup_user|{subgroup_hash}", callback_data=f"tariff_subgroup_user|{subgroup_hash}",
) )
) )
tariff_menu_buttons = await process_tariff_menu( 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 = insert_hook_buttons(builder, tariff_menu_buttons)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) 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 = "" discount_message = ""
if discount_info and discount_info.get("available"): 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 offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
expires_at = discount_info["expires_at"] expires_at = discount_info["expires_at"]
@@ -289,7 +284,11 @@ async def handle_key_creation(
reply_markup=builder.as_markup(), 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) await state.set_state(Form.waiting_for_server_selection)
finally: finally:
@@ -297,7 +296,7 @@ async def handle_key_creation(
@router.callback_query(F.data.startswith("tariff_subgroup_user|")) @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] subgroup_hash = callback.data.split("|")[1]
data = await state.get_data() data = await state.get_data()
cluster_name = data.get("cluster_name") cluster_name = data.get("cluster_name")
@@ -312,14 +311,16 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
) )
return return
tariffs = await get_tariffs_for_cluster(session, cluster_name) tariffs_for_cluster = await get_tariffs_for_cluster(session, cluster_name)
filtered = [] filtered: list[dict[str, Any]] = []
if tariffs: if tariffs_for_cluster:
group_code = tariffs[0].get("group_code") group_code = tariffs_for_cluster[0].get("group_code")
if group_code: if group_code:
tariffs = await get_tariffs(session, group_code=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: if not filtered:
await edit_or_send_message( 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 language_code = callback.from_user.language_code
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
for t in filtered: for tariff in filtered:
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code) price_text = await format_for_user(session, tg_id, tariff.get("price_rub", 0), language_code)
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=f"{t['name']}{price_txt}", text=f"{tariff['name']}{price_text}",
callback_data=f"select_tariff_plan|{t['id']}", callback_data=f"select_tariff_plan|{tariff['id']}",
) )
) )
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_tariff_group_list")) 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") @router.callback_query(F.data == "back_to_tariff_group_list")
async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any): async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.get_data()
tg_id = callback.from_user.id tg_id = callback.from_user.id
await handle_key_creation( await handle_key_creation(
tg_id=tg_id, 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( async def create_key(
tg_id: int, tg_id: int,
expiry_time, expiry_time: datetime,
state, state: FSMContext | None,
session, session: AsyncSession,
message_or_query=None, message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None, old_key_name: str | None = None,
plan: int = 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): 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 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)) 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: if use_country_selection:
await key_country_mode( await key_country_mode(
tg_id=tg_id, tg_id=tg_id,
@@ -492,6 +415,9 @@ async def create_key(
message_or_query=message_or_query, message_or_query=message_or_query,
old_key_name=old_key_name, old_key_name=old_key_name,
plan=plan, plan=plan,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=selected_price_rub,
) )
else: else:
await key_cluster_mode( await key_cluster_mode(
@@ -501,4 +427,7 @@ async def create_key(
session=session, session=session,
message_or_query=message_or_query, message_or_query=message_or_query,
plan=plan, plan=plan,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=selected_price_rub,
) )
+9 -2
View File
@@ -2,12 +2,19 @@ __all__ = ("router",)
from aiogram import 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_cluster_mode import router as cluster_router
from .key_country_mode import router as country_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 from .key_discount_mode import router as discount_router
router = Router(name="key_mode_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,
)
+75 -45
View File
@@ -12,17 +12,18 @@ from aiogram.types import (
WebAppInfo, WebAppInfo,
) )
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import update
from bot import bot from bot import bot
from config import REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL 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 ( from database import (
get_key_details, get_key_details,
get_tariff_by_id,
get_trial, get_trial,
update_balance, update_balance,
update_trial, update_trial,
) )
from database.models import Key
from handlers.buttons import ( from handlers.buttons import (
CONNECT_DEVICE, CONNECT_DEVICE,
MAIN_MENU, MAIN_MENU,
@@ -32,7 +33,12 @@ from handlers.buttons import (
TV_BUTTON, TV_BUTTON,
) )
from handlers.keys.operations import create_key_on_cluster 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 ( from handlers.utils import (
edit_or_send_message, edit_or_send_message,
generate_random_email, generate_random_email,
@@ -59,7 +65,10 @@ async def key_cluster_mode(
state, state,
session, session,
message_or_query: Message | CallbackQuery | None = None, 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 target_message = None
safe_to_edit = False safe_to_edit = False
@@ -85,19 +94,26 @@ async def key_cluster_mode(
data = await state.get_data() if state else {} data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False) is_trial = data.get("is_trial", False)
device_limit = 0 if selected_device_limit is None:
traffic_limit_gb = 0 selected_device_limit = data.get("config_selected_device_limit") or data.get("selected_device_limit")
if plan: if selected_traffic_gb is None:
tariff = await get_tariff_by_id(session, plan) selected_traffic_gb = data.get("config_selected_traffic_gb") or data.get("selected_traffic_limit_gb")
if tariff:
if tariff.get("device_limit") is not None: effective_tariff_id = plan or data.get("tariff_id")
device_limit = int(tariff["device_limit"])
if tariff.get("traffic_limit") is not None: device_limit, traffic_limit_bytes = await get_effective_limits_for_key(
traffic_limit_gb = int(tariff["traffic_limit"]) 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( 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: if forced_cluster:
@@ -119,6 +135,16 @@ async def key_cluster_mode(
await bot.send_message(chat_id=tg_id, text=error_message) await bot.send_message(chat_id=tg_id, text=error_message)
return 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( await create_key_on_cluster(
cluster_id=least_loaded_cluster, cluster_id=least_loaded_cluster,
tg_id=tg_id, tg_id=tg_id,
@@ -128,29 +154,36 @@ async def key_cluster_mode(
plan=plan, plan=plan,
session=session, session=session,
hwid_limit=device_limit, hwid_limit=device_limit,
traffic_limit_bytes=traffic_limit_gb, traffic_limit_bytes=traffic_limit_bytes,
is_trial=is_trial, is_trial=is_trial,
) )
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}") 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) key_record = await get_key_details(session, email)
if not key_record: if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}") raise ValueError(f"Ключ не найден после создания: {email}")
public_link = key_record.get("key") final_link = key_record.get("link", "")
remnawave_link = key_record.get("remnawave_link")
final_link = public_link or remnawave_link or ""
if is_trial: if is_trial:
trial_status = await get_trial(session, tg_id) trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]: if trial_status in [0, -1]:
await update_trial(session, tg_id, 1) await update_trial(session, tg_id, 1)
if data.get("tariff_id"): if price_to_charge:
tariff = await get_tariff_by_id(session, data["tariff_id"]) await update_balance(session, tg_id, -int(price_to_charge))
if tariff:
await update_balance(session, tg_id, -tariff["price_rub"])
except Exception as e: except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}") logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
@@ -169,14 +202,14 @@ async def key_cluster_mode(
vless_enabled = False vless_enabled = False
try: try:
if plan: if plan:
ti = await get_tariff_by_id(session, plan) vless_enabled = await resolve_vless_enabled(session, plan)
vless_enabled = bool(ti.get("vless")) if ti else False
elif key_record.get("tariff_id"): elif key_record.get("tariff_id"):
ti = await get_tariff_by_id(session, key_record["tariff_id"]) vless_enabled = await resolve_vless_enabled(session, key_record["tariff_id"])
vless_enabled = bool(ti.get("vless")) if ti else False
except Exception: except Exception:
vless_enabled = False vless_enabled = False
tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE"))
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if vless_enabled: if vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) 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://")) and final_link.startswith(("http://", "https://"))
): ):
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) 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: else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
else: else:
@@ -208,32 +242,28 @@ async def key_cluster_mode(
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if await process_intercept_key_creation_message( 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 return
hook_commands = await process_key_creation_complete( 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: if hook_commands:
builder = insert_hook_buttons(builder, hook_commands) builder = insert_hook_buttons(builder, hook_commands)
expiry_time_local = expiry_time.astimezone(moscow_tz) key_message_text = await build_key_created_message(
expiry_time_local - datetime.now(moscow_tz) session=session,
key_record=key_record,
tariff_info = None final_link=final_link,
if plan: selected_device_limit=selected_device_limit,
tariff_info = await get_tariff_by_id(session, plan) selected_traffic_gb=selected_traffic_gb,
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,
) )
default_media_path = "img/pic.jpg" default_media_path = "img/pic.jpg"
+129 -79
View File
@@ -19,13 +19,12 @@ from bot import bot
from config import ( from config import (
ADMIN_PASSWORD, ADMIN_PASSWORD,
ADMIN_USERNAME, ADMIN_USERNAME,
HAPP_CRYPTOLINK,
REMNAWAVE_LOGIN, REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD, REMNAWAVE_PASSWORD,
REMNAWAVE_WEBAPP, REMNAWAVE_WEBAPP,
SUPPORT_CHAT_URL, SUPPORT_CHAT_URL,
) )
from core.bootstrap import MODES_CONFIG from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
from database import ( from database import (
add_user, add_user,
check_server_name_by_cluster, check_server_name_by_cluster,
@@ -37,7 +36,7 @@ from database import (
update_balance, update_balance,
update_trial, update_trial,
) )
from database.models import Key, Server, ServerSpecialgroup, Tariff from database.models import Key, Server, ServerSpecialgroup
from handlers.buttons import ( from handlers.buttons import (
BACK, BACK,
CONNECT_DEVICE, CONNECT_DEVICE,
@@ -49,7 +48,11 @@ from handlers.buttons import (
) )
from handlers.keys.operations import create_client_on_server from handlers.keys.operations import create_client_on_server
from handlers.keys.operations.aggregated_links import make_aggregated_link 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 ( from handlers.utils import (
ALLOWED_GROUP_CODES, ALLOWED_GROUP_CODES,
edit_or_send_message, edit_or_send_message,
@@ -70,8 +73,8 @@ from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_usern
router = Router() router = Router()
moscow_tz = pytz.timezone("Europe/Moscow") moscow_tz = pytz.timezone("Europe/Moscow")
GB = 1024 * 1024 * 1024
async def key_country_mode( async def key_country_mode(
@@ -80,8 +83,8 @@ async def key_country_mode(
state: FSMContext, state: FSMContext,
session: AsyncSession, session: AsyncSession,
message_or_query: Message | CallbackQuery | None = None, message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None, old_key_name: str | None = None,
plan: int = None, plan: int | None = None,
): ):
target_message = None target_message = None
safe_to_edit = False safe_to_edit = False
@@ -99,7 +102,10 @@ async def key_country_mode(
data = await state.get_data() if state else {} data = await state.get_data() if state else {}
forced_cluster = await process_cluster_override( 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: if forced_cluster:
least_loaded_cluster = forced_cluster least_loaded_cluster = forced_cluster
@@ -115,9 +121,9 @@ async def key_country_mode(
return return
subgroup_title = None subgroup_title = None
tariff = None tariff: dict[str, Any] | None = None
if plan: if plan:
tariff = await get_tariff_by_id(session, plan) tariff = await get_tariff_by_id(session, int(plan))
if tariff: if tariff:
subgroup_title = tariff.get("subgroup_title") subgroup_title = tariff.get("subgroup_title")
@@ -140,7 +146,7 @@ async def key_country_mode(
return return
server_ids = [s["id"] for s in servers] server_ids = [s["id"] for s in servers]
groups_map = {} groups_map: dict[int, list[str]] = {}
if server_ids: if server_ids:
r = await session.execute( r = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where( select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
@@ -181,7 +187,7 @@ async def key_country_mode(
await bot.send_message(chat_id=tg_id, text=text) await bot.send_message(chat_id=tg_id, text=text)
return return
available_servers = [] available_servers: list[str] = []
tasks = [asyncio.create_task(check_server_availability(dict(server), session)) for server in servers] tasks = [asyncio.create_task(check_server_availability(dict(server), session)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True) 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"] cluster_name = cluster_info["cluster_name"]
key_tariff_id = record.get("tariff_id") key_tariff_id = record.get("tariff_id")
tariff_obj = None tariff_dict: dict[str, Any] | None = None
subgroup_title = None subgroup_title = None
if key_tariff_id: if key_tariff_id:
res = await session.execute(select(Tariff).where(Tariff.id == key_tariff_id)) tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
tariff_obj = res.scalar_one_or_none() if tariff_dict:
if tariff_obj: subgroup_title = tariff_dict.get("subgroup_title")
subgroup_title = tariff_obj.subgroup_title
q = ( q = (
select( select(
@@ -278,7 +283,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
return return
server_ids = [s["id"] for s in servers] server_ids = [s["id"] for s in servers]
groups_map = {} groups_map: dict[int, list[str]] = {}
if server_ids: if server_ids:
r = await session.execute( r = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where( 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: for server in servers:
server["special_groups"] = [g for g in groups_map.get(server["id"], []) if g in ALLOWED_GROUP_CODES] 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 = [ tasks = [
asyncio.create_task( asyncio.create_task(
check_server_availability( check_server_availability(
@@ -314,7 +319,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
if subgroup_title and available_servers: if subgroup_title and available_servers:
available_servers_dict = [s for s in servers if s["server_name"] in 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: if filtered_servers:
available_servers = [s["server_name"] for s in filtered_servers] available_servers = [s["server_name"] for s in filtered_servers]
else: else:
@@ -327,9 +337,9 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
) )
return return
if available_servers and tariff_obj: if available_servers and tariff_dict:
special = None 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: if gc and gc in ALLOWED_GROUP_CODES:
special = gc special = gc
@@ -417,13 +427,13 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
try: try:
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz) expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
await finalize_key_creation( await finalize_key_creation(
tg_id, tg_id=tg_id,
expiry_time, expiry_time=expiry_time,
selected_country, selected_country=selected_country,
state, state=state,
session, session=session,
callback_query, callback_query=callback_query,
old_key_name, old_key_name=old_key_name,
) )
finally: finally:
fsm_data = await state.get_data() fsm_data = await state.get_data()
@@ -438,7 +448,7 @@ async def finalize_key_creation(
state: FSMContext | None, state: FSMContext | None,
session: AsyncSession, session: AsyncSession,
callback_query: CallbackQuery, callback_query: CallbackQuery,
old_key_name: str = None, old_key_name: str | None = None,
tariff_id: int | None = None, tariff_id: int | None = None,
): ):
from_user = callback_query.from_user from_user = callback_query.from_user
@@ -456,6 +466,7 @@ async def finalize_key_creation(
expiry_time = expiry_time.astimezone(moscow_tz) expiry_time = expiry_time.astimezone(moscow_tz)
old_key_details: dict[str, Any] | None = None
if old_key_name: if old_key_name:
old_key_details = await get_key_details(session, old_key_name) old_key_details = await get_key_details(session, old_key_name)
if not old_key_details: if not old_key_details:
@@ -476,24 +487,51 @@ async def finalize_key_creation(
email = key_name.lower() email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000) expiry_timestamp = int(expiry_time.timestamp() * 1000)
traffic_limit_bytes = None
device_limit = 0
data = await state.get_data() if state else {} data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False) is_trial = data.get("is_trial", False)
if data.get("tariff_id") or tariff_id: selected_traffic_gb = data.get("config_selected_traffic_gb")
tariff_id = data.get("tariff_id") or tariff_id if selected_traffic_gb is None:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id)) selected_traffic_gb = data.get("selected_traffic_limit_gb")
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
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 public_link = None
remnawave_link = None remnawave_link = None
@@ -512,7 +550,7 @@ async def finalize_key_creation(
cluster_name = cluster_info["cluster_name"] cluster_name = cluster_info["cluster_name"]
is_full_remnawave = await is_full_remnawave_cluster(cluster_name, session) 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"] old_server_id = old_key_details["server_id"]
if old_server_id: if old_server_id:
result = await session.execute(select(Server).where(Server.server_name == 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})") raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z" expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = { user_data: dict[str, Any] = {
"username": email, "username": email,
"trafficLimitStrategy": "NO_RESET", "trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at, "expireAt": expire_at,
@@ -583,7 +621,8 @@ async def finalize_key_creation(
if need_vless_key and not remnawave_link: if need_vless_key and not remnawave_link:
links = sub.get("links") or [] links = sub.get("links") or []
remnawave_link = next( 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: if not remnawave_link:
@@ -611,9 +650,11 @@ async def finalize_key_creation(
session=session, session=session,
plan=tariff_id, plan=tariff_id,
is_trial=is_trial, 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 = [ cluster_all = [
{ {
"server_name": server_info.server_name, "server_name": server_info.server_name,
@@ -640,7 +681,11 @@ async def finalize_key_creation(
public_link = link_to_show public_link = link_to_show
if old_key_name: 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://"): if public_link and public_link.startswith("vless://"):
update_data["key"] = public_link update_data["key"] = public_link
elif public_link and public_link.startswith("http"): elif public_link and public_link.startswith("http"):
@@ -659,17 +704,17 @@ async def finalize_key_creation(
remnawave_link=remnawave_link, remnawave_link=remnawave_link,
server_id=selected_country, server_id=selected_country,
tariff_id=tariff_id, 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) session.add(new_key)
if is_trial: if is_trial:
trial_status = await get_trial(session, tg_id) trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]: if trial_status in [0, -1]:
await update_trial(session, tg_id, 1) await update_trial(session, tg_id, 1)
if tariff_id: if not is_trial and price_to_charge:
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id)) await update_balance(session, tg_id, -int(price_to_charge))
row = result.scalar_one_or_none()
if row:
await update_balance(session, tg_id, -row)
await session.commit() await session.commit()
@@ -696,13 +741,16 @@ async def finalize_key_creation(
session=session, session=session,
) )
tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE"))
if panel_type == "remnawave" or is_full_remnawave: if panel_type == "remnawave" or is_full_remnawave:
if is_vless: if is_vless:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else: else:
if use_webapp and webapp_url: if use_webapp and webapp_url:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=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: else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
else: else:
@@ -713,32 +761,35 @@ async def finalize_key_creation(
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if await process_intercept_key_creation_message( 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 return
hook_commands = await process_key_creation_complete( 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: if hook_commands:
builder = insert_hook_buttons(builder, hook_commands) builder = insert_hook_buttons(builder, hook_commands)
t = tariff.name if tariff else "" key_record = await get_key_details(session, key_name)
subgroup_title = tariff.subgroup_title if tariff and tariff.subgroup_title else "" final_link_for_message = final_link or (key_record.get("link") if key_record else None) or "Ссылка не найдена"
traffic = tariff.traffic_limit if tariff and tariff.traffic_limit else 0 message_text = await build_key_created_message(
devices = tariff.device_limit if tariff and tariff.device_limit else 0 session=session,
key_record=key_record,
key_message_text = key_message_success( final_link=final_link_for_message,
public_link or remnawave_link or "Ссылка не найдена", selected_device_limit=selected_device_limit,
tariff_name=t, selected_traffic_gb=selected_traffic_gb,
traffic_limit=traffic,
device_limit=devices,
subgroup_title=subgroup_title,
) )
await edit_or_send_message( await edit_or_send_message(
target_message=callback_query.message, target_message=callback_query.message,
text=key_message_text, text=message_text,
reply_markup=builder.as_markup(), reply_markup=builder.as_markup(),
media_path="img/pic.jpg", 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} доступен.") logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True return True
else: xui = AsyncApi(
xui = AsyncApi( server_info["api_url"],
server_info["api_url"], username=ADMIN_USERNAME,
username=ADMIN_USERNAME, password=ADMIN_PASSWORD,
password=ADMIN_PASSWORD, logger=logger,
logger=logger, )
) await asyncio.wait_for(xui.login(), timeout=5.0)
await asyncio.wait_for(xui.login(), timeout=5.0) logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.") return True
return True
except TimeoutError: except TimeoutError:
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.") logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
+14 -13
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select from sqlalchemy import select
@@ -12,12 +13,11 @@ from database import get_keys, get_tariffs
from database.models import Notification from database.models import Notification
from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION
from handlers.notifications.notify_kb import build_tariffs_keyboard 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.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
from handlers.utils import format_discount_time_left from handlers.utils import format_discount_time_left
from logger import logger from logger import logger
from .key_create import select_tariff_plan
router = Router() router = Router()
@@ -54,10 +54,10 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
expires_at = last_time + timedelta(hours=discount_active_hours) expires_at = last_time + timedelta(hours=discount_active_hours)
await callback.message.edit_text( await callback.message.edit_text(
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>" "🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
f"💎 <b>Специальные тарифы</b> — доступные только для вас!\n" "💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n" "🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
f"</blockquote>\n" "</blockquote>\n"
f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите свой шанс!</b>", f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите свой шанс!</b>",
reply_markup=builder.as_markup(), 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|")) @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: try:
tariff_id = int(callback.data.split("|")[1]) tariff_id = int(callback.data.split("|")[1])
fake_callback = CallbackQuery.model_construct( 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=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expires_at = last_time + timedelta(hours=discount_active_hours)
await callback.message.edit_text( await callback.message.edit_text(
f"🎯 <b>УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>" "🎯 <b>УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n" "💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n" "🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
f"</blockquote>\n" "</blockquote>\n"
f"⏰ <b>Время ограничено: {format_discount_time_left(last_time, discount_active_hours)} — не упустите шанс!</b>", f"⏰ <b>Время ограничено: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите шанс!</b>",
reply_markup=builder.as_markup(), reply_markup=builder.as_markup(),
) )
else: else:
tariffs = await get_tariffs(session, group_code="discounts_max") tariffs = await get_tariffs(session=session, group_code="discounts_max")
if not tariffs: if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.") await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return return
+235 -18
View File
@@ -15,12 +15,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot from bot import bot
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
from core.bootstrap import NOTIFICATIONS_CONFIG from core.bootstrap import NOTIFICATIONS_CONFIG
from core.settings.tariffs_config import normalize_tariff_config
from database import ( from database import (
check_tariff_exists, check_tariff_exists,
get_balance, get_balance,
get_key_by_server, get_key_by_server,
get_key_details, get_key_details,
get_tariff_by_id, get_tariff_by_id,
reset_key_current_limits_to_selected,
save_key_config_with_mode,
update_balance, update_balance,
update_key_expiry, 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.keys.operations import renew_key_in_cluster
from handlers.payments.currency_rates import format_for_user from handlers.payments.currency_rates import format_for_user
from handlers.payments.fast_payment_flow import try_fast_payment_flow 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 ( from handlers.texts import (
DISCOUNT_OFFER_MESSAGE, DISCOUNT_OFFER_MESSAGE,
DISCOUNT_OFFER_STEP2, DISCOUNT_OFFER_STEP2,
@@ -58,6 +62,7 @@ moscow_tz = pytz.timezone("Europe/Moscow")
@router.callback_query(F.data.startswith("renew_key|")) @router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
"""Обрабатывает нажатие кнопки продления конкретного ключа."""
tg_id = callback_query.message.chat.id tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1] 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) current_tariff = await get_tariff_by_id(session, tariff_id)
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"] forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
additional_groups = await process_renewal_forbidden_groups( additional_groups = await process_renewal_forbidden_groups(chat_id=tg_id, admin=False, session=session)
chat_id=tg_id, admin=False, session=session
)
forbidden_groups.extend(additional_groups) forbidden_groups.extend(additional_groups)
if current_tariff["group_code"] not in forbidden_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, []): for t in grouped_tariffs.get(None, []):
price_text = await format_for_user(session, tg_id, t["price_rub"], language_code) 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( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=f"{t['name']}{price_text}", text=button_text,
callback_data=f"renew_plan|{t['id']}", 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|")) @router.callback_query(F.data.startswith("renew_subgroup|"))
async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession): async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
"""Показывает тарифы внутри выбранной подгруппы при продлении."""
try: try:
subgroup_hash = callback.data.split("|")[1] subgroup_hash = callback.data.split("|")[1]
@@ -317,9 +325,13 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
for t in filtered: for t in filtered:
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code) 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( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=f"{t['name']}{price_txt}", text=button_text,
callback_data=f"renew_plan|{t['id']}", 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 = InlineKeyboardBuilder()
hook_builder.attach(builder) hook_builder.attach(builder)
hook_commands = await process_renew_tariffs( hook_commands = await process_renew_tariffs(chat_id=callback.from_user.id, admin=False, session=session)
chat_id=callback.from_user.id, admin=False, session=session
)
if hook_commands: if hook_commands:
hook_builder = insert_hook_buttons(hook_builder, 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|")) @router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any): async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
"""Обрабатывает выбор конкретного тарифа для продления."""
tg_id = callback_query.from_user.id tg_id = callback_query.from_user.id
tariff_id = int(callback_query.data.split("|")[1]) tariff_id = int(callback_query.data.split("|")[1])
@@ -391,7 +402,6 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
return return
duration_days = tariff["duration_days"] duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0 total_gb = tariff["traffic_limit"] or 0
record = await get_key_by_server(session, tg_id, client_id) 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: else:
new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000) 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) 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: if balance < cost:
required_amount = ceil(cost - balance) 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}") 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: 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)) result = await session.execute(select(Server).where(Server.cluster_name == server_or_cluster).limit(1))
server = result.scalars().first() server = result.scalars().first()
if server: if server:
@@ -487,7 +634,11 @@ async def complete_key_renewal(
cost: float, cost: float,
callback_query: CallbackQuery | None, callback_query: CallbackQuery | None,
tariff_id: int, tariff_id: int,
selected_device_limit: int | None = None,
selected_traffic_limit: int | None = None,
selected_price_rub: int | None = None,
): ):
"""Продлевает подписку, обновляет лимиты и данные в БД."""
try: try:
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)") 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} не найден.") logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
return 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 = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace( formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"), 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( response_message = get_renewal_message(
tariff_name=tariff["name"], tariff_name=tariff["name"],
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0, traffic_limit=traffic_limit_gb_effective,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0, device_limit=device_limit_effective,
expiry_date=formatted_expiry_date, expiry_date=formatted_expiry_date,
subgroup_title=tariff.get("subgroup_title", ""), 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 current_subgroup = None
try: try:
current_tariff_id = key_info.get("tariff_id") current_tariff_id = key_info.get("tariff_id")
@@ -556,7 +759,7 @@ async def complete_key_renewal(
new_expiry_time=new_expiry_time, new_expiry_time=new_expiry_time,
total_gb=total_gb, total_gb=total_gb,
session=session, 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, reset_traffic=True,
target_subgroup=target_subgroup, target_subgroup=target_subgroup,
old_subgroup=old_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 session.execute(update(Key).where(Key.email == email).values(tariff_id=tariff_id))
await update_balance(session, tg_id, -cost) 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 = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}")) builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
hook_commands = await process_renewal_complete( hook_commands = await process_renewal_complete(
+93 -51
View File
@@ -4,7 +4,6 @@ import os
import re import re
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
@@ -13,7 +12,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import ( from config import (
@@ -28,9 +27,12 @@ from config import (
USE_COUNTRY_SELECTION, USE_COUNTRY_SELECTION,
) )
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG 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 database.models import Key
from handlers.buttons import ( from handlers.buttons import (
ADDONS_BUTTON_DEVICES,
ADDONS_BUTTON_DEVICES_TRAFFIC,
ADDONS_BUTTON_TRAFFIC,
ALIAS, ALIAS,
BACK, BACK,
CHANGE_LOCATION, CHANGE_LOCATION,
@@ -45,6 +47,7 @@ from handlers.buttons import (
TV_BUTTON, TV_BUTTON,
UNFREEZE, UNFREEZE,
) )
from handlers.tariffs.tariff_display import GB, get_key_tariff_addons_state
from handlers.texts import ( from handlers.texts import (
DAYS_LEFT_MESSAGE, DAYS_LEFT_MESSAGE,
FROZEN_SUBSCRIPTION_MSG, FROZEN_SUBSCRIPTION_MSG,
@@ -82,7 +85,10 @@ class RenameKeyState(StatesGroup):
@router.callback_query(F.data == "view_keys") @router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs") @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): if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message target_message = callback_query_or_message.message
else: 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) 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() builder = InlineKeyboardBuilder()
if records: if records:
@@ -133,13 +139,13 @@ async def build_keys_response(records, session):
formatted_date_full = "без срока действия" formatted_date_full = "без срока действия"
is_vless = False is_vless = False
if hasattr(record, "tariff_id") and record.tariff_id: if getattr(record, "tariff_id", None):
try: try:
tariff = await get_tariff_by_id(session, record.tariff_id) from handlers.tariffs.tariff_display import resolve_vless_enabled
if tariff and tariff.get("vless"):
is_vless = True is_vless = await resolve_vless_enabled(session, int(record.tariff_id))
except Exception: except Exception:
pass is_vless = False
icon = "📶" if is_vless else "🔑" 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|")) @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] key_name = callback_query.data.split("|")[1]
image_path = os.path.join("img", "pic_view.jpg") image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path) 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) record = await get_key_details(session, key_name)
if not record: if not record:
await message.answer("<b>Информация о подписке не найдена.</b>") builder = InlineKeyboardBuilder()
return builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
return "<b>Информация о подписке не найдена.</b>", 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"] is_frozen = record["is_frozen"]
client_id = record.get("client_id") client_id = record.get("client_id")
remnawave_link = record.get("remnawave_link") final_link = record.get("link")
key = record.get("key")
final_link = key or remnawave_link
builder = InlineKeyboardBuilder() 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=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys")) builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message( return FROZEN_SUBSCRIPTION_MSG, builder.as_markup(), True
target_message=message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
return
expiry_time = record["expiry_time"] expiry_time = record["expiry_time"]
server_name = record["server_id"] 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)) 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 is_full_remnawave = await is_full_task
tariff = await tariff_task if tariff_task else None
hwid_count = 0 hwid_count = 0
remna_used_gb = None 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) user_data = await api.get_user_by_uuid(client_id)
if user_data: if user_data:
used_bytes = user_data.get("usedTrafficBytes", 0) 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: except Exception as error:
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {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)) 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)) remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
happ_cryptolink_enabled = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)) 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,
server_name if country_selection_enabled else None, server_name if country_selection_enabled else None,
hwid_count=hwid_count if device_limit is not None else 0, hwid_count=hwid_count if device_limit is not None else 0,
tariff_name=tariff_duration, tariff_name=tariff_name,
traffic_limit=traffic_limit, traffic_limit=traffic_limit_gb,
device_limit=device_limit, device_limit=device_limit,
subgroup_title=subgroup_title, subgroup_title=subgroup_title,
is_remnawave=is_full_remnawave, 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, 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 is_full_remnawave and final_link and use_webapp and not happ_cryptolink_enabled:
if vless_enabled: if vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else: else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) 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: else:
if vless_enabled: if vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) 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}")) 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)) hwid_reset_enabled = bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON))
qrcode_enabled = bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)) 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)) 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=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
module_buttons = await process_view_key_menu(key_name=key_name, session=session) module_buttons = await process_view_key_menu(key_name=key_name, session=session)
builder = insert_hook_buttons(builder, module_buttons) 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( await edit_or_send_message(
target_message=message, target_message=message,
text=response_message, text=text,
reply_markup=builder.as_markup(), reply_markup=reply_markup,
media_path=image_path, media_path=image_path,
) )
@router.callback_query(F.data.startswith("reset_hwid|")) @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] key_name = callback_query.data.split("|")[1]
record_task = asyncio.create_task(get_key_details(session, key_name)) 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) await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
if await process_after_hwid_reset( 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 = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) 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()) await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=builder.as_markup())
else: else:
await callback_query.message.edit_caption( await callback_query.message.edit_caption(
caption="✅ Устройства сброшены", reply_markup=builder.as_markup() caption="✅ Устройства сброшены",
reply_markup=builder.as_markup(),
) )
return return
-2
View File
@@ -1,5 +1,3 @@
# handlers/keys/operations/__init__.py
from .creation import create_client_on_server, create_key_on_cluster from .creation import create_client_on_server, create_key_on_cluster
from .deletion import delete_key_from_cluster from .deletion import delete_key_from_cluster
from .renewal import renew_key_in_cluster from .renewal import renew_key_in_cluster
+1 -1
View File
@@ -3,8 +3,8 @@ import asyncio
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import HAPP_CRYPTOLINK, LEGACY_LINKS, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE 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 core.bootstrap import MODES_CONFIG
from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
from logger import logger from logger import logger
from panels._3xui import get_vless_link_for_client, get_xui_instance from panels._3xui import get_vless_link_for_client, get_xui_instance
from panels.remnawave import RemnawaveAPI from panels.remnawave import RemnawaveAPI
+72 -37
View File
@@ -5,10 +5,9 @@ from datetime import datetime
from sqlalchemy import update from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession 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 import get_servers, get_tariff_by_id, store_key
from database.models import User from database.models import User
from core.bootstrap import MODES_CONFIG
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
from hooks.processors import process_extract_cryptolink_from_result from hooks.processors import process_extract_cryptolink_from_result
from logger import ( from logger import (
@@ -60,16 +59,35 @@ async def create_key_on_cluster(
tariff = None tariff = None
subgroup_title = None subgroup_title = None
need_vless_key = False need_vless_key = False
traffic_limit_bytes_value = 0
device_limit_value = 0
if plan is not None: if plan is not None:
tariff = await get_tariff_by_id(session, plan) tariff = await get_tariff_by_id(session, plan)
if not tariff: if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.") raise ValueError(f"Тариф с id={plan} не найден.")
if traffic_limit_bytes is None: 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: 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") subgroup_title = tariff.get("subgroup_title")
need_vless_key = bool(tariff.get("vless")) 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: if subgroup_title:
subgroup_servers = [s for s in enabled_servers if subgroup_title in s.get("tariff_subgroups", [])] 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 enabled_servers = subgroup_servers
else: else:
logger.warning( logger.warning(
f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. Использую весь кластер." f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. "
f"Использую весь кластер."
) )
special = None special = None
@@ -87,13 +106,15 @@ async def create_key_on_cluster(
gc = (tariff.get("group_code") or "").lower() gc = (tariff.get("group_code") or "").lower()
if gc in ALLOWED_GROUP_CODES: if gc in ALLOWED_GROUP_CODES:
special = gc special = gc
if special: if special:
bound_servers = [s for s in enabled_servers if special in (s.get("special_groups") or [])] bound_servers = [s for s in enabled_servers if special in (s.get("special_groups") or [])]
if bound_servers: if bound_servers:
enabled_servers = bound_servers enabled_servers = bound_servers
else: else:
logger.info( logger.info(
f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. Использую весь кластер." f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. "
f"Использую весь кластер."
) )
remnawave_servers = [ remnawave_servers = [
@@ -129,6 +150,7 @@ async def create_key_on_cluster(
short_uuid = None short_uuid = None
if remnawave_link and "/" in remnawave_link: if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1] short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = { user_data = {
"username": email, "username": email,
"trafficLimitStrategy": "NO_RESET", "trafficLimitStrategy": "NO_RESET",
@@ -137,11 +159,15 @@ async def create_key_on_cluster(
"activeInternalSquads": inbound_ids, "activeInternalSquads": inbound_ids,
"uuid": client_id, "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: if short_uuid:
user_data["shortUuid"] = 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}") logger.debug(f"{PANEL_REMNA} Данные для создания клиента: {user_data}")
result = await remna.create_user(user_data) result = await remna.create_user(user_data)
if result: if result:
@@ -189,25 +215,27 @@ async def create_key_on_cluster(
plan=plan, plan=plan,
session=session, session=session,
is_trial=is_trial, is_trial=is_trial,
total_traffic_limit_bytes=traffic_limit_bytes_value,
device_limit_value=device_limit_value,
) )
else: else:
await asyncio.gather( tasks = [
*[ create_client_on_server(
create_client_on_server( server,
server, tg_id,
tg_id, final_client_id,
final_client_id, email,
email, expiry_timestamp,
expiry_timestamp, semaphore,
semaphore, plan=plan,
plan=plan, session=session,
session=session, is_trial=is_trial,
is_trial=is_trial, total_traffic_limit_bytes=traffic_limit_bytes_value,
) device_limit_value=device_limit_value,
for server in xui_servers )
], for server in xui_servers
return_exceptions=True, ]
) await asyncio.gather(*tasks, return_exceptions=True)
cluster_all = enabled_servers cluster_all = enabled_servers
subgroup_code = subgroup_title if subgroup_title else None subgroup_code = subgroup_title if subgroup_title else None
@@ -254,12 +282,15 @@ async def create_client_on_server(
email: str, email: str,
expiry_timestamp: int, expiry_timestamp: int,
semaphore: asyncio.Semaphore, semaphore: asyncio.Semaphore,
plan: int = None, plan: int | None = None,
session=None, session: AsyncSession | None = None,
is_trial: bool = False, is_trial: bool = False,
total_traffic_limit_bytes: int = 0,
device_limit_value: int = 0,
): ):
logger.debug( 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: async with semaphore:
@@ -278,23 +309,27 @@ async def create_client_on_server(
unique_email = email unique_email = email
sub_id = unique_email sub_id = unique_email
total_gb_value = 0 if plan is not None and (total_traffic_limit_bytes == 0 or device_limit_value == 0):
device_limit_value = 0
if plan is not None:
tariff = await get_tariff_by_id(session, plan) tariff = await get_tariff_by_id(session, plan)
logger.debug(f"{PANEL_XUI} [Tariff Debug] Получен тариф: {tariff}") logger.debug(f"{PANEL_XUI} [Tariff Debug] Получен тариф: {tariff}")
if not tariff: if not tariff:
raise ValueError(f"{PANEL_XUI} Тариф с id={plan} не найден.") raise ValueError(f"{PANEL_XUI} Тариф с id={plan} не найден.")
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0 if total_traffic_limit_bytes == 0:
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 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: try:
logger.debug( 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( await add_client(
xui, xui,
ClientConfig( ClientConfig(
+3 -2
View File
@@ -14,6 +14,7 @@ from database import (
update_key_expiry, update_key_expiry,
update_key_link, update_key_link,
) )
from hooks.processors import process_get_cryptolink_after_renewal
from logger import ( from logger import (
CLOGGER as logger, CLOGGER as logger,
PANEL_REMNA, PANEL_REMNA,
@@ -24,7 +25,6 @@ from panels.remnawave import RemnawaveAPI
from .aggregated_links import make_aggregated_link from .aggregated_links import make_aggregated_link
from .subgroup_migration import migrate_between_subgroups 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): async def resolve_cluster(session: AsyncSession, cluster_id: str):
@@ -293,7 +293,8 @@ async def renew_key_in_cluster(
remna_link_override = None remna_link_override = None
if remna_ok and cluster_scope: if remna_ok and cluster_scope:
remnawave_nodes = [ 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 str(s.get("panel_type", "3x-ui")).lower() == "remnawave" and s.get("inbound_id")
] ]
if remnawave_nodes: if remnawave_nodes:
+10 -3
View File
@@ -5,8 +5,8 @@ from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE 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 core.bootstrap import MODES_CONFIG
from database import filter_cluster_by_subgroup, update_key_client_id
from logger import ( from logger import (
CLOGGER as logger, CLOGGER as logger,
PANEL_REMNA, PANEL_REMNA,
@@ -33,6 +33,13 @@ async def ensure_on_remnawave(
if not servers: if not servers:
return None, None 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")] inbounds = [s.get("inbound_id") for s in servers if s.get("inbound_id")]
api = RemnawaveAPI(servers[0]["api_url"]) api = RemnawaveAPI(servers[0]["api_url"])
@@ -77,8 +84,8 @@ async def ensure_on_remnawave(
} }
if traffic_bytes > 0: if traffic_bytes > 0:
payload["trafficLimitBytes"] = traffic_bytes 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) created = await api.create_user(payload)
new_uuid = created.get("uuid") if isinstance(created, dict) else None new_uuid = created.get("uuid") if isinstance(created, dict) else None
+24 -8
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE 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 import filter_cluster_by_subgroup, get_servers, get_tariff_by_id, store_key
from database.models import Key, Tariff 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 handlers.utils import get_least_loaded_cluster
from logger import ( from logger import (
CLOGGER as logger, 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"] xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None remnawave_client_id = None
remnawave_key = None
remnawave_link_value = None remnawave_link_value = None
if remnawave_servers: if remnawave_servers:
@@ -106,7 +106,6 @@ async def update_key_on_cluster(
if result: if result:
remnawave_client_id = result.get("uuid") remnawave_client_id = result.get("uuid")
remnawave_link_value = result.get("subscriptionUrl") remnawave_link_value = result.get("subscriptionUrl")
remnawave_key = None
logger.info(f"{PANEL_REMNA} Клиент заново создан, uuid={remnawave_client_id}") logger.info(f"{PANEL_REMNA} Клиент заново создан, uuid={remnawave_client_id}")
else: else:
@@ -181,7 +180,7 @@ async def update_subscription(
remnawave_link: str = None, remnawave_link: str = None,
) -> None: ) -> None:
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email)) 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: if not record:
raise ValueError(f"The key {email} does not exist in database") 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 remnawave_link = remnawave_link or record.remnawave_link
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" 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 tariff = None
subgroup_code = getattr(record, "subgroup_code", None) subgroup_code = getattr(record, "subgroup_code", None)
@@ -243,10 +246,20 @@ async def update_subscription(
return return
cluster_servers = prefiltered cluster_servers = prefiltered
traffic_limit = None traffic_limit_gb = None
device_limit = None device_limit = 0
if tariff:
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None 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 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( new_client_id, remnawave_link_value = await update_key_on_cluster(
@@ -256,7 +269,7 @@ async def update_subscription(
expiry_time=expiry_time, expiry_time=expiry_time,
cluster_id=new_cluster_id, cluster_id=new_cluster_id,
session=session, session=session,
traffic_limit=traffic_limit, traffic_limit=traffic_limit_gb,
device_limit=device_limit, device_limit=device_limit,
remnawave_link=remnawave_link, remnawave_link=remnawave_link,
subgroup_code=subgroup_code, subgroup_code=subgroup_code,
@@ -287,4 +300,7 @@ async def update_subscription(
server_id=new_cluster_id, server_id=new_cluster_id,
tariff_id=tariff_id, tariff_id=tariff_id,
alias=alias, alias=alias,
selected_device_limit=selected_device_limit,
selected_traffic_limit=selected_traffic_limit,
selected_price_rub=selected_price_rub,
) )
+73 -40
View File
@@ -26,24 +26,27 @@ from database import (
add_notification, add_notification,
check_notification_time, check_notification_time,
check_notifications_bulk, check_notifications_bulk,
check_tariff_exists,
delete_key, delete_key,
delete_notification, delete_notification,
get_all_keys, get_all_keys,
get_balance, get_balance,
get_last_notification_time, get_last_notification_time,
get_tariff_by_id,
get_tariffs_for_cluster,
update_balance, update_balance,
update_key_expiry, update_key_expiry,
update_key_tariff, 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.keys.operations import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import ( from handlers.notifications.notify_kb import (
build_change_tariff_kb, build_change_tariff_kb,
build_notification_expired_kb, build_notification_expired_kb,
build_notification_kb, build_notification_kb,
) )
from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key
from handlers.texts import ( from handlers.texts import (
KEY_CANNOT_RENEW_CURRENT, KEY_CANNOT_RENEW_CURRENT,
KEY_DELETED_MSG, KEY_DELETED_MSG,
@@ -382,6 +385,27 @@ async def handle_expired_keys(
tariff = tariffs[0] if tariffs else None tariff = tariffs[0] if tariffs else None
if tariff and balance >= tariff["price_rub"]: 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( await process_auto_renew_or_notify(
bot, bot,
session, session,
@@ -389,12 +413,7 @@ async def handle_expired_keys(
notification_id, notification_id,
1, 1,
"notify_expired.jpg", "notify_expired.jpg",
get_renewal_message( standard_caption,
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", ""),
),
) )
except Exception as error: except Exception as error:
@@ -485,6 +504,7 @@ async def process_auto_renew_or_notify(
standard_photo: str, standard_photo: str,
standard_caption: str, standard_caption: str,
): ):
"""Пытается автопродлить ключ или отправить уведомление."""
tg_id = key.tg_id tg_id = key.tg_id
email = key.email or "" email = key.email or ""
renew_notification_id = f"{email}_renew" renew_notification_id = f"{email}_renew"
@@ -508,33 +528,39 @@ async def process_auto_renew_or_notify(
selected_tariff = None selected_tariff = None
if not tariff_id: if tariff_id and await check_tariff_exists(conn, tariff_id):
selected_tariff = None current_tariff = await get_tariff_by_id(conn, tariff_id)
else:
if 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: if current_tariff and current_tariff["group_code"] not in forbidden_groups:
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn) stored_price = getattr(key, "selected_price_rub", None)
for hook_result in hook_results: renewal_cost = float(stored_price) if stored_price is not None else float(current_tariff["price_rub"])
additional_groups = hook_result.get("additional_groups", []) if balance >= renewal_cost:
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"]:
selected_tariff = current_tariff selected_tariff = current_tariff
else:
selected_tariff = None
else: else:
selected_tariff = None selected_tariff = None
else:
selected_tariff = None
if not selected_tariff: 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 use_change_tariff_kb = False
message_text = None
if tariff_id and await check_tariff_exists(conn, tariff_id): if tariff_id and await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(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: if current_tariff["group_code"] in forbidden_groups:
use_change_tariff_kb = True use_change_tariff_kb = True
message_text = KEY_CANNOT_RENEW_CURRENT.format( message_text = KEY_CANNOT_RENEW_CURRENT.format(
email=email, email=email,
hours_left_formatted=expiry_data["hours_left_formatted"], hours_left_formatted=expiry_data["hours_left_formatted"],
@@ -562,7 +587,6 @@ async def process_auto_renew_or_notify(
) )
else: else:
use_change_tariff_kb = True use_change_tariff_kb = True
message_text = KEY_CANNOT_RENEW_CURRENT.format( message_text = KEY_CANNOT_RENEW_CURRENT.format(
email=email, email=email,
hours_left_formatted=expiry_data["hours_left_formatted"], hours_left_formatted=expiry_data["hours_left_formatted"],
@@ -581,17 +605,29 @@ async def process_auto_renew_or_notify(
keyboard = build_notification_kb(email) keyboard = build_notification_kb(email)
await add_notification(conn, tg_id, notification_id) 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) await send_notification(bot, tg_id, standard_photo, text_to_send, keyboard)
return return
client_id = key.client_id client_id = key.client_id
current_expiry = key.expiry_time current_expiry = key.expiry_time
duration_days = selected_tariff["duration_days"] duration_days = selected_tariff["duration_days"]
renewal_cost = selected_tariff["price_rub"]
traffic_limit = selected_tariff["traffic_limit"] stored_price = getattr(key, "selected_price_rub", None)
device_limit = selected_tariff["device_limit"] renewal_cost = float(stored_price) if stored_price is not None else float(selected_tariff["price_rub"])
total_gb = traffic_limit if traffic_limit else 0
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 = ( new_expiry_time = (
current_expiry current_expiry
@@ -600,7 +636,6 @@ async def process_auto_renew_or_notify(
) + duration_days * 24 * 60 * 60 * 1000 ) + 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 = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace( formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"), datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)), 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, client_id=client_id,
new_expiry_time=int(new_expiry_time), new_expiry_time=int(new_expiry_time),
total_gb=total_gb, total_gb=total_gb,
hwid_device_limit=device_limit, hwid_device_limit=device_limit_effective,
session=conn, session=conn,
target_subgroup=key_subgroup, target_subgroup=key_subgroup,
old_subgroup=key_subgroup, old_subgroup=key_subgroup,
@@ -633,10 +668,8 @@ async def process_auto_renew_or_notify(
renewed_message = get_renewal_message( renewed_message = get_renewal_message(
tariff_name=selected_tariff["name"], tariff_name=selected_tariff["name"],
traffic_limit=selected_tariff.get("traffic_limit") traffic_limit=traffic_limit_gb_effective,
if selected_tariff.get("traffic_limit") is not None device_limit=device_limit_effective,
else 0,
device_limit=selected_tariff.get("device_limit") if selected_tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date, expiry_date=formatted_expiry_date,
subgroup_title=selected_tariff.get("subgroup_title", ""), subgroup_title=selected_tariff.get("subgroup_title", ""),
) )
+67 -37
View File
@@ -15,11 +15,15 @@ from aiogram.exceptions import (
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import create_blocked_user, get_tariff_by_id from database import create_blocked_user
from handlers.utils import format_hours 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 from logger import logger
moscow_tz = pytz.timezone("Europe/Moscow")
async def send_messages_with_limit( async def send_messages_with_limit(
bot: Bot, bot: Bot,
messages: list[dict], messages: list[dict],
@@ -27,10 +31,6 @@ async def send_messages_with_limit(
source_file: str = None, source_file: str = None,
messages_per_second: int = 25, messages_per_second: int = 25,
): ):
"""
Отправляет сообщения с ограничением по количеству сообщений в секунду.
Возвращает список результатов отправки (True для успеха, False для ошибки).
"""
batch_size = messages_per_second batch_size = messages_per_second
results = [] 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): async def try_add_blocked_user(tg_id: int, session: AsyncSession, source_file: str | None):
"""
Добавляет пользователя в список заблокировавших бота, если требуется.
"""
if source_file == "special_notifications" and session: if source_file == "special_notifications" and session:
try: try:
await create_blocked_user(session, tg_id) await create_blocked_user(session, tg_id)
@@ -108,9 +105,6 @@ async def send_notification(
caption: str, caption: str,
keyboard: InlineKeyboardMarkup | None = None, keyboard: InlineKeyboardMarkup | None = None,
) -> bool: ) -> bool:
"""
Отправляет уведомление пользователю.
"""
if image_filename is None: if image_filename is None:
return await _send_text_notification(bot, tg_id, caption, keyboard) return await _send_text_notification(bot, tg_id, caption, keyboard)
@@ -131,7 +125,6 @@ async def _send_photo_notification(
caption: str, caption: str,
keyboard: InlineKeyboardMarkup | None = None, keyboard: InlineKeyboardMarkup | None = None,
) -> bool: ) -> bool:
"""Отправляет уведомление с изображением."""
try: try:
async with aiofiles.open(photo_path, "rb") as image_file: async with aiofiles.open(photo_path, "rb") as image_file:
image_data = await image_file.read() image_data = await image_file.read()
@@ -152,7 +145,6 @@ async def _send_text_notification(
caption: str, caption: str,
keyboard: InlineKeyboardMarkup | None = None, keyboard: InlineKeyboardMarkup | None = None,
) -> bool: ) -> bool:
"""Отправляет текстовое уведомление."""
try: try:
await bot.send_message(tg_id, caption, reply_markup=keyboard) await bot.send_message(tg_id, caption, reply_markup=keyboard)
return True return True
@@ -164,35 +156,73 @@ async def _send_text_notification(
async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int) -> dict: 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 if not expiry_timestamp:
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600)) return {
hours_left_formatted = ( "hours_left_formatted": "",
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!" "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) 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_name = ""
tariff_details = "" subgroup_title = ""
traffic_limit_gb = 0
device_limit = 0
if getattr(key, "tariff_id", None): try:
tariff = await get_tariff_by_id(session, key.tariff_id) name, subgroup_title, traffic_limit_gb, device_limit, _ = await get_key_tariff_display(
if tariff: session=session,
tariff_name = tariff.get("name") or "" key_record=record,
traffic_limit = tariff.get("traffic_limit") or 0 )
device_limit = tariff.get("device_limit") or 0 if name:
subgroup_title = tariff.get("subgroup_title", "") tariff_name = name
traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ" except Exception as error:
devices_text = "безлимит" if device_limit <= 0 else str(device_limit) logger.warning(f"[NOTIFY] Ошибка при получении тарифных лимитов для {email}: {error}")
lines = []
if subgroup_title: traffic_text = "безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
lines.append(subgroup_title) devices_text = "безлимит" if device_limit == 0 else str(device_limit)
lines.append(f"Трафик: {traffic_text}")
lines.append(f"Устройств: {devices_text}") lines = []
tariff_details = "\n" + "\n".join(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 { return {
"hours_left_formatted": hours_left_formatted, "hours_left_formatted": hours_left_formatted,
+6
View File
@@ -0,0 +1,6 @@
ALLOWED_TEMP_PAYMENT_STATES = {
"waiting_for_payment",
"waiting_for_renewal_payment",
"waiting_for_gift_payment",
"waiting_for_addons_payment",
}
+2 -6
View File
@@ -11,6 +11,7 @@ from handlers.payments.currency_rates import format_for_user
from handlers.texts import DEFAULT_PAYMENT_MESSAGE from handlers.texts import DEFAULT_PAYMENT_MESSAGE
from handlers.utils import edit_or_send_message from handlers.utils import edit_or_send_message
from logger import logger from logger import logger
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
from .service import ( from .service import (
HELEKET_PAYMENT_METHODS, HELEKET_PAYMENT_METHODS,
@@ -52,12 +53,7 @@ async def handle_custom_amount_input_heleket(
tg_id = from_user.id tg_id = from_user.id
temp_data = await get_temporary_data(session, tg_id) temp_data = await get_temporary_data(session, tg_id)
valid_states = [ if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES:
"waiting_for_payment",
"waiting_for_renewal_payment",
"waiting_for_gift_payment",
]
if not temp_data or temp_data["state"] not in valid_states:
await edit_or_send_message( await edit_or_send_message(
target_message=message, target_message=message,
text="❌ Не удалось получить данные для оплаты.", text="❌ Не удалось получить данные для оплаты.",
+2 -6
View File
@@ -11,6 +11,7 @@ from handlers.payments.currency_rates import format_for_user
from handlers.texts import DEFAULT_PAYMENT_MESSAGE from handlers.texts import DEFAULT_PAYMENT_MESSAGE
from handlers.utils import edit_or_send_message from handlers.utils import edit_or_send_message
from logger import logger from logger import logger
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
from .service import ( from .service import (
KASSAI_PAYMENT_METHODS, KASSAI_PAYMENT_METHODS,
@@ -72,12 +73,7 @@ async def _handle_custom_amount_input_kassai(
tg_id = from_user.id tg_id = from_user.id
temp_data = await get_temporary_data(session, tg_id) temp_data = await get_temporary_data(session, tg_id)
valid_states = [ if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES:
"waiting_for_payment",
"waiting_for_renewal_payment",
"waiting_for_gift_payment",
]
if not temp_data or temp_data["state"] not in valid_states:
await edit_or_send_message( await edit_or_send_message(
target_message=message, target_message=message,
text="❌ Не удалось получить данные для оплаты.", text="❌ Не удалось получить данные для оплаты.",
+3 -4
View File
@@ -15,11 +15,11 @@ from handlers.payments.keyboards import (
pay_keyboard as build_pay_keyboard, pay_keyboard as build_pay_keyboard,
payment_options_for_user, payment_options_for_user,
) )
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM
from handlers.payments.currency_rates import format_for_user from handlers.payments.currency_rates import format_for_user
from handlers.utils import edit_or_send_message from handlers.utils import edit_or_send_message
from logger import logger from logger import logger
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
from .service import create_and_store_robokassa_payment from .service import create_and_store_robokassa_payment
@@ -117,7 +117,7 @@ async def handle_custom_amount_input(
from_user = event.from_user from_user = event.from_user
tg_id = from_user.id tg_id = from_user.id
temp_data = await get_temporary_data(session, tg_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="❌ Не удалось получить данные для оплаты.") await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.")
return return
amount = int(temp_data["data"].get("required_amount", 0)) 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) await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup)
except Exception as e: except Exception as e:
from logger import logger as _lg logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
_lg.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
await edit_or_send_message( await edit_or_send_message(
target_message=message, target_message=message,
text="Произошла ошибка при создании платежа. Попробуйте позже.", text="Произошла ошибка при создании платежа. Попробуйте позже.",
+1 -1
View File
@@ -44,7 +44,7 @@ from handlers.buttons import (
from handlers.captcha import generate_captcha from handlers.captcha import generate_captcha
from handlers.coupons import activate_coupon from handlers.coupons import activate_coupon
from handlers.instructions.instructions import send_instructions 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.keys.key_view import process_callback_or_message_view_keys
from handlers.payments.gift import handle_gift_link from handlers.payments.gift import handle_gift_link
from handlers.profile import process_callback_view_profile from handlers.profile import process_callback_view_profile
+10
View File
@@ -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)
+35
View File
@@ -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)
+940
View File
@@ -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()
+814
View File
@@ -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()
+166
View File
@@ -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"<blockquote>{current_text}</blockquote>\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 = "<blockquote>" + "\n".join(params_lines) + "</blockquote>"
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)
+936
View File
@@ -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)
+348
View File
@@ -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,
)
+3 -3
View File
@@ -1,6 +1,7 @@
from typing import Any from typing import Any
from logger import logger from logger import logger
from .hooks import run_hooks from .hooks import run_hooks
@@ -148,8 +149,8 @@ async def process_extract_cryptolink_from_result(
return None return None
try: try:
from core.bootstrap import MODES_CONFIG
from config import HAPP_CRYPTOLINK from config import HAPP_CRYPTOLINK
from core.bootstrap import MODES_CONFIG
base_use_crypto_link = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)) base_use_crypto_link = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
use_crypto_link = await process_happ_cryptolink_override( use_crypto_link = await process_happ_cryptolink_override(
@@ -196,9 +197,9 @@ async def process_get_cryptolink_after_renewal(
try: try:
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
from database import get_tariff_by_id from database import get_tariff_by_id
from handlers.keys.operations.utils import is_plan_vless from handlers.keys.operations.utils import is_plan_vless
from panels.remnawave import RemnawaveAPI
remna = RemnawaveAPI(remnawave_nodes[0]["api_url"]) remna = RemnawaveAPI(remnawave_nodes[0]["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD): if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
@@ -592,4 +593,3 @@ async def process_connect_device_menu(
except Exception as e: except Exception as e:
logger.warning(f"[CONNECT_DEVICE_MENU] Ошибка при обработке хука: {e}") logger.warning(f"[CONNECT_DEVICE_MENU] Ошибка при обработке хука: {e}")
return [] return []
+2 -2
View File
@@ -5,7 +5,7 @@ from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, Update from aiogram.types import CallbackQuery, Message, Update
from config import ADMIN_ID 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 import async_session_maker
from database.models import Admin from database.models import Admin
@@ -17,7 +17,7 @@ class MaintenanceModeMiddleware(BaseMiddleware):
event: Update, event: Update,
data: dict[str, Any], data: dict[str, Any],
) -> Any: ) -> Any:
maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False)) maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))
if not maintenance_enabled: if not maintenance_enabled:
return await handler(event, data) return await handler(event, data)
+1 -2
View File
@@ -23,10 +23,9 @@ PING_SEMAPHORE = asyncio.Semaphore(3)
async def ping_server(server_ip: str) -> bool: async def ping_server(server_ip: str) -> bool:
"""Пингует сервер через ICMP или TCP 443, если ICMP недоступен."""
async with PING_SEMAPHORE: async with PING_SEMAPHORE:
try: 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: if response is not None and response is not False:
return True return True
return await check_tcp_connection(server_ip, 443) return await check_tcp_connection(server_ip, 443)
+1 -1
View File
@@ -7,8 +7,8 @@ from aiogram.types import BufferedInputFile
from sqlalchemy import exists, func, join, not_, select from sqlalchemy import exists, func, join, not_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Referral, Tariff, User
from core.constants import PAYMENT_SYSTEMS_EXCLUDED from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, Referral, Tariff, User
async def export_users_csv(session: AsyncSession) -> BufferedInputFile: async def export_users_csv(session: AsyncSession) -> BufferedInputFile:
+1 -1
View File
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
def get_version() -> 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()}"