currency/fast flow for everyone/optimization and cache/new payment system/and more
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
formatting:
|
||||
format:
|
||||
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||
|
||||
lint:
|
||||
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
format-payments:
|
||||
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
|
||||
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ from aiogram.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from database import async_session_maker
|
||||
from filters.private import IsPrivateFilter
|
||||
from logger import logger
|
||||
from utils.modules_loader import load_modules_from_folder
|
||||
from database import async_session_maker
|
||||
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
@@ -129,4 +129,3 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
|
||||
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
|
||||
except Exception as exception:
|
||||
logger.error(f"Неожиданная ошибка в error handler: {exception}")
|
||||
|
||||
|
||||
+2
-4
@@ -113,6 +113,7 @@ def restore_from_backup():
|
||||
try:
|
||||
mtime = os.path.getmtime(BACK_DIR)
|
||||
from datetime import datetime
|
||||
|
||||
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
console.print(f"[cyan]Обнаружен бэкап от: {dt}[/cyan]")
|
||||
except Exception:
|
||||
@@ -129,10 +130,7 @@ def restore_from_backup():
|
||||
install_rsync_if_needed()
|
||||
|
||||
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
|
||||
rc = subprocess.run(
|
||||
f"rsync -a --delete {BACK_DIR}/ {PROJECT_DIR}/",
|
||||
shell=True
|
||||
).returncode
|
||||
rc = subprocess.run(f"rsync -a --delete {BACK_DIR}/ {PROJECT_DIR}/", shell=True).returncode
|
||||
if rc != 0:
|
||||
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
|
||||
return
|
||||
|
||||
@@ -20,4 +20,4 @@ async def get_hot_leads(session: AsyncSession):
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [row.tg_id for row in result]
|
||||
return [row.tg_id for row in result]
|
||||
|
||||
+2
-2
@@ -45,9 +45,9 @@ async def store_key(
|
||||
|
||||
try:
|
||||
await clear_hot_lead_notifications(session, tg_id)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
|
||||
await session.rollback()
|
||||
|
||||
+11
-19
@@ -3,18 +3,8 @@ import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, DateTime, Float, ForeignKey, Integer, Numeric, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
|
||||
|
||||
|
||||
@@ -43,14 +33,16 @@ class User(DictLikeMixin, Base):
|
||||
is_bot = Column(Boolean, default=False)
|
||||
balance = Column(Float, default=0.0)
|
||||
trial = Column(Integer, default=0)
|
||||
preferred_currency = Column(String(10), nullable=False, server_default="RUB", index=True)
|
||||
source_code = Column(
|
||||
String,
|
||||
ForeignKey(
|
||||
"tracking_sources.code",
|
||||
ondelete="SET NULL",
|
||||
onupdate="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
nullable=True, )
|
||||
nullable=True,
|
||||
)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -114,6 +106,10 @@ class Payment(DictLikeMixin, Base):
|
||||
payment_system = Column(String)
|
||||
status = Column(String)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
original_amount = Column(Numeric(18, 8), nullable=True)
|
||||
currency = Column(String(10), nullable=False, server_default="RUB")
|
||||
payment_id = Column(String(128), nullable=True, index=True)
|
||||
metadata_ = Column("metadata", JSONB, nullable=True)
|
||||
|
||||
|
||||
class Coupon(DictLikeMixin, Base):
|
||||
@@ -131,11 +127,7 @@ class Coupon(DictLikeMixin, Base):
|
||||
class CouponUsage(DictLikeMixin, Base):
|
||||
__tablename__ = "coupon_usages"
|
||||
|
||||
coupon_id = Column(
|
||||
Integer,
|
||||
ForeignKey("coupons.id", ondelete="CASCADE"),
|
||||
primary_key=True
|
||||
)
|
||||
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), primary_key=True)
|
||||
user_id = Column(BigInteger, primary_key=True)
|
||||
used_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
+13
-13
@@ -70,32 +70,27 @@ async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
|
||||
result = await session.execute(
|
||||
select(Notification.notification_type, Notification.last_notification_time)
|
||||
.where(Notification.tg_id == tg_id)
|
||||
.where(Notification.notification_type.in_(['hot_lead_step_2', 'hot_lead_step_3']))
|
||||
.where(Notification.notification_type.in_(["hot_lead_step_2", "hot_lead_step_3"]))
|
||||
.order_by(Notification.last_notification_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
row = result.first()
|
||||
if not row:
|
||||
return {"available": False}
|
||||
|
||||
|
||||
notification_type, last_time = row
|
||||
|
||||
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
if current_time > expires_at:
|
||||
return {"available": False}
|
||||
|
||||
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"type": notification_type,
|
||||
"tariff_group": tariff_group,
|
||||
"expires_at": expires_at
|
||||
}
|
||||
|
||||
|
||||
return {"available": True, "type": notification_type, "tariff_group": tariff_group, "expires_at": expires_at}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
|
||||
return {"available": False}
|
||||
@@ -106,7 +101,12 @@ async def clear_hot_lead_notifications(session: AsyncSession, tg_id: int):
|
||||
await session.execute(
|
||||
delete(Notification).where(
|
||||
Notification.tg_id == tg_id,
|
||||
Notification.notification_type.in_(['hot_lead_step_1', 'hot_lead_step_2', 'hot_lead_step_3', 'hot_lead_step_2_expired'])
|
||||
Notification.notification_type.in_([
|
||||
"hot_lead_step_1",
|
||||
"hot_lead_step_2",
|
||||
"hot_lead_step_3",
|
||||
"hot_lead_step_2_expired",
|
||||
]),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
+142
-21
@@ -1,7 +1,8 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from pytz import timezone
|
||||
from sqlalchemy import insert, select
|
||||
from sqlalchemy import and_, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,33 +13,153 @@ from logger import logger
|
||||
MOSCOW_TZ = timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def add_payment(session: AsyncSession, tg_id: int, amount: float, payment_system: str):
|
||||
async def add_payment(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
amount: float,
|
||||
payment_system: str,
|
||||
*,
|
||||
status: str = "success",
|
||||
currency: str = "RUB",
|
||||
payment_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
original_amount: float | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||
stmt = insert(Payment).values(
|
||||
tg_id=tg_id,
|
||||
amount=amount,
|
||||
payment_system=payment_system,
|
||||
status="success",
|
||||
created_at=now_moscow,
|
||||
stmt = (
|
||||
insert(Payment)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
amount=amount,
|
||||
payment_system=payment_system,
|
||||
status=status,
|
||||
created_at=now_moscow,
|
||||
currency=currency,
|
||||
payment_id=payment_id,
|
||||
metadata_=metadata,
|
||||
original_amount=original_amount,
|
||||
)
|
||||
.returning(Payment.id)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
result = await session.execute(stmt)
|
||||
internal_id = result.scalar_one()
|
||||
await session.commit()
|
||||
logger.info(f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}")
|
||||
logger.info(
|
||||
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
|
||||
)
|
||||
return internal_id
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при добавлении платежа: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
|
||||
async def get_last_payments(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
limit: int = 3,
|
||||
statuses: list[str] | None = None,
|
||||
):
|
||||
query = select(Payment).where(Payment.tg_id == tg_id)
|
||||
|
||||
if statuses:
|
||||
query = query.where(Payment.status.in_(statuses))
|
||||
|
||||
query = query.order_by(Payment.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
payments = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"tg_id": p.tg_id,
|
||||
"amount": p.amount,
|
||||
"currency": p.currency,
|
||||
"status": p.status,
|
||||
"payment_system": p.payment_system,
|
||||
"payment_id": p.payment_id,
|
||||
"created_at": p.created_at,
|
||||
"metadata": p.metadata_,
|
||||
"original_amount": p.original_amount,
|
||||
}
|
||||
for p in payments
|
||||
]
|
||||
|
||||
|
||||
async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | None:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Payment).where(Payment.tg_id == tg_id).order_by(Payment.created_at.desc()).limit(limit)
|
||||
)
|
||||
payments = result.scalars().all()
|
||||
logger.info(f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}")
|
||||
return [dict(p.__dict__) for p in payments]
|
||||
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if payment:
|
||||
logger.info(f"Найден платёж id={internal_id}")
|
||||
return dict(payment.__dict__)
|
||||
logger.info(f"Платёж id={internal_id} не найден")
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при получении платежей пользователя {tg_id}: {e}")
|
||||
return []
|
||||
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_payment_status(
|
||||
session: AsyncSession,
|
||||
internal_id: int,
|
||||
new_status: str,
|
||||
*,
|
||||
payment_id: str | None = None,
|
||||
metadata_patch: dict | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if not payment:
|
||||
logger.info(f"Не удалось сменить статус: платёж id={internal_id} не найден")
|
||||
return False
|
||||
|
||||
payment.status = new_status
|
||||
if payment_id is not None:
|
||||
payment.payment_id = payment_id
|
||||
if metadata_patch:
|
||||
base = payment.metadata_ or {}
|
||||
base.update(metadata_patch)
|
||||
payment.metadata_ = base
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Статус платежа id={internal_id} изменён на {new_status}")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при смене статуса платежа id={internal_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | None:
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if payment:
|
||||
logger.info(f"Найден платёж payment_id={pid}")
|
||||
return dict(payment.__dict__)
|
||||
logger.info(f"Платёж payment_id={pid} не найден")
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cancel_expired_pending_payments(session: AsyncSession) -> int:
|
||||
cutoff = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(minutes=60)
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
and_(
|
||||
Payment.status.in_(("pending", "issued", "processing", "awaiting_choice")),
|
||||
Payment.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
.values(status="cancelled")
|
||||
.returning(Payment.id)
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return len(res.fetchall())
|
||||
|
||||
+13
-11
@@ -34,15 +34,9 @@ async def count_active_keys(session: AsyncSession) -> int:
|
||||
|
||||
|
||||
async def count_trial_keys(session: AsyncSession) -> int:
|
||||
trial_tariffs_subquery = (
|
||||
select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
)
|
||||
|
||||
return await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.tariff_id.in_(trial_tariffs_subquery))
|
||||
)
|
||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
return await session.scalar(select(func.count()).select_from(Key).where(Key.tariff_id.in_(trial_tariffs_subquery)))
|
||||
|
||||
|
||||
async def get_tariff_distribution(
|
||||
@@ -93,7 +87,11 @@ async def count_total_referrals(session: AsyncSession) -> int:
|
||||
async def sum_payments_since(session: AsyncSession, since: date) -> float:
|
||||
result = await session.scalar(
|
||||
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
and_(Payment.created_at >= since, Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
and_(
|
||||
Payment.created_at >= since,
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
return round(float(result), 2)
|
||||
@@ -105,6 +103,7 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
|
||||
and_(
|
||||
Payment.created_at >= start,
|
||||
Payment.created_at < end,
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
@@ -115,7 +114,10 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
|
||||
async def sum_total_payments(session: AsyncSession) -> float:
|
||||
result = await session.scalar(
|
||||
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"])
|
||||
and_(
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
return round(float(result), 2)
|
||||
|
||||
+33
-56
@@ -1,6 +1,6 @@
|
||||
import hashlib
|
||||
from collections import defaultdict
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, func, insert, select, update
|
||||
@@ -35,15 +35,15 @@ async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group
|
||||
return None
|
||||
|
||||
|
||||
async def get_tariffs(session: AsyncSession, tariff_id: int = None, group_code: str = None, with_subgroup_weights: bool = False):
|
||||
async def get_tariffs(
|
||||
session: AsyncSession, tariff_id: int = None, group_code: str = None, with_subgroup_weights: bool = False
|
||||
):
|
||||
try:
|
||||
if tariff_id:
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
elif group_code:
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code)
|
||||
.order_by(Tariff.sort_order, Tariff.id)
|
||||
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.sort_order, Tariff.id)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(select(Tariff).order_by(Tariff.sort_order, Tariff.id))
|
||||
@@ -55,26 +55,21 @@ async def get_tariffs(session: AsyncSession, tariff_id: int = None, group_code:
|
||||
if tariffs_without_order:
|
||||
for tariff in tariffs_without_order:
|
||||
tariff["sort_order"] = 1
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff["id"]).values(sort_order=1)
|
||||
)
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff["id"]).values(sort_order=1))
|
||||
await session.commit()
|
||||
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for t in tariffs:
|
||||
grouped[t.get("subgroup_title")].append(t)
|
||||
|
||||
|
||||
subgroup_weights = {}
|
||||
for subgroup, tariffs_list in grouped.items():
|
||||
if subgroup:
|
||||
total_weight = sum(t.get("sort_order", 1) for t in tariffs_list)
|
||||
subgroup_weights[subgroup] = total_weight
|
||||
|
||||
return {
|
||||
'tariffs': tariffs,
|
||||
'subgroup_weights': subgroup_weights
|
||||
}
|
||||
|
||||
|
||||
return {"tariffs": tariffs, "subgroup_weights": subgroup_weights}
|
||||
|
||||
return tariffs
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
|
||||
@@ -128,15 +123,13 @@ async def create_tariff(session: AsyncSession, data: dict):
|
||||
group_code = data.get("group_code")
|
||||
if group_code:
|
||||
result = await session.execute(
|
||||
select(func.max(Tariff.sort_order))
|
||||
.where(Tariff.group_code == group_code, Tariff.sort_order.isnot(None))
|
||||
select(func.max(Tariff.sort_order)).where(
|
||||
Tariff.group_code == group_code, Tariff.sort_order.isnot(None)
|
||||
)
|
||||
)
|
||||
max_order = result.scalar() or 0
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(func.max(Tariff.sort_order))
|
||||
.where(Tariff.sort_order.isnot(None))
|
||||
)
|
||||
result = await session.execute(select(func.max(Tariff.sort_order)).where(Tariff.sort_order.isnot(None)))
|
||||
max_order = result.scalar() or 0
|
||||
|
||||
data["sort_order"] = max_order + 1
|
||||
@@ -191,18 +184,14 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int):
|
||||
|
||||
async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Tariff.sort_order).where(Tariff.id == tariff_id)
|
||||
)
|
||||
result = await session.execute(select(Tariff.sort_order).where(Tariff.id == tariff_id))
|
||||
sort_order = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if sort_order is None:
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff_id).values(sort_order=1)
|
||||
)
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=1))
|
||||
await session.commit()
|
||||
return 1
|
||||
|
||||
|
||||
return sort_order
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
|
||||
@@ -213,10 +202,8 @@ async def move_tariff_up(session: AsyncSession, tariff_id: int) -> bool:
|
||||
try:
|
||||
current_order = await get_tariff_sort_order(session, tariff_id)
|
||||
new_order = max(1, current_order - 1)
|
||||
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order)
|
||||
)
|
||||
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
@@ -229,10 +216,8 @@ async def move_tariff_down(session: AsyncSession, tariff_id: int) -> bool:
|
||||
try:
|
||||
current_order = await get_tariff_sort_order(session, tariff_id)
|
||||
new_order = current_order + 1
|
||||
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order)
|
||||
)
|
||||
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
@@ -243,20 +228,16 @@ async def move_tariff_down(session: AsyncSession, tariff_id: int) -> bool:
|
||||
|
||||
async def initialize_tariff_sort_orders(session: AsyncSession, group_code: str) -> bool:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
|
||||
)
|
||||
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
|
||||
if not tariffs:
|
||||
return True
|
||||
|
||||
for i, tariff in enumerate(tariffs):
|
||||
new_sort_order = 1 + i
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff.id).values(sort_order=new_sort_order)
|
||||
)
|
||||
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=new_sort_order))
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
@@ -267,23 +248,19 @@ async def initialize_tariff_sort_orders(session: AsyncSession, group_code: str)
|
||||
|
||||
async def initialize_all_tariff_weights(session: AsyncSession) -> bool:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.sort_order.is_(None))
|
||||
)
|
||||
result = await session.execute(select(Tariff).where(Tariff.sort_order.is_(None)))
|
||||
tariffs_without_weight = result.scalars().all()
|
||||
|
||||
|
||||
if not tariffs_without_weight:
|
||||
return True
|
||||
|
||||
for tariff in tariffs_without_weight:
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff.id).values(sort_order=1)
|
||||
)
|
||||
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=1))
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при инициализации весов тарифов: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import func, insert, not_, select, and_
|
||||
from sqlalchemy import and_, func, insert, not_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -8,6 +8,7 @@ from logger import logger
|
||||
|
||||
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
|
||||
|
||||
|
||||
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
|
||||
try:
|
||||
stmt = insert(TrackingSource).values(
|
||||
@@ -76,14 +77,13 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
return dt.strftime("%Y-%m")
|
||||
|
||||
src_row = await session.execute(
|
||||
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at)
|
||||
.where(TrackingSource.code == code)
|
||||
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at).where(TrackingSource.code == code)
|
||||
)
|
||||
src = src_row.first()
|
||||
if not src:
|
||||
return None
|
||||
|
||||
src_name, src_code, created_at = src
|
||||
_src_name, _src_code, created_at = src
|
||||
|
||||
reg_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
@@ -212,11 +212,7 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
|
||||
month_expr_trials,
|
||||
func.count(func.distinct(User.tg_id)).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (User.trial == 1)
|
||||
& (User.created_at >= created_at)
|
||||
)
|
||||
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
|
||||
.group_by(month_expr_trials)
|
||||
.order_by(month_expr_trials)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ from .donate import router as donate_router
|
||||
from .instructions import router as instructions_router
|
||||
from .keys import router as keys_router
|
||||
from .notifications import router as notifications_router
|
||||
from .pay import router as pay_router
|
||||
from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .refferal import router as refferal_router
|
||||
@@ -22,7 +21,6 @@ router.include_routers(
|
||||
start_router,
|
||||
captcha_router,
|
||||
profile_router,
|
||||
pay_router,
|
||||
donate_router,
|
||||
coupons_router,
|
||||
notifications_router,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from collections import defaultdict
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -34,8 +35,8 @@ async def admin_gift_menu(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_gift_create")
|
||||
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
|
||||
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
|
||||
tariffs = [t for t in tariffs_data['tariffs'] if t.get('is_active')]
|
||||
subgroup_weights = tariffs_data['subgroup_weights']
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||
|
||||
if not tariffs:
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -45,28 +46,24 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
|
||||
|
||||
grouped_tariffs = defaultdict(list)
|
||||
for t in tariffs:
|
||||
grouped_tariffs[t.get('subgroup_title')].append(t)
|
||||
grouped_tariffs[t.get("subgroup_title")].append(t)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
if t.get('duration_days') % 30 == 0:
|
||||
duration_text = format_months(t.get('duration_days') // 30)
|
||||
if t.get("duration_days") % 30 == 0:
|
||||
duration_text = format_months(t.get("duration_days") // 30)
|
||||
else:
|
||||
duration_text = format_days(t.get('duration_days'))
|
||||
duration_text = format_days(t.get("duration_days"))
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{t.get('name')} – {duration_text}",
|
||||
callback_data=f"admin_gift_select|{t.get('id')}"
|
||||
text=f"{t.get('name')} – {duration_text}", callback_data=f"admin_gift_select|{t.get('id')}"
|
||||
)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted(
|
||||
[k for k in grouped_tariffs if k],
|
||||
key=lambda x: (subgroup_weights.get(x, 999999), x)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
|
||||
|
||||
for subgroup in sorted_subgroups:
|
||||
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
|
||||
builder.row(
|
||||
@@ -92,17 +89,17 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs(session, group_code="gifts")
|
||||
filtered = [t for t in tariffs if t.get('subgroup_title') == subgroup and t.get('is_active')]
|
||||
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
|
||||
if not filtered:
|
||||
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
if t.get('duration_days') % 30 == 0:
|
||||
duration_text = format_months(t.get('duration_days') // 30)
|
||||
if t.get("duration_days") % 30 == 0:
|
||||
duration_text = format_months(t.get("duration_days") // 30)
|
||||
else:
|
||||
duration_text = format_days(t.get('duration_days'))
|
||||
duration_text = format_days(t.get("duration_days"))
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import re
|
||||
|
||||
from asyncio import sleep
|
||||
from datetime import datetime
|
||||
|
||||
@@ -12,7 +12,7 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMar
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, Server, Tariff, User, BlockedUser, ManualBan
|
||||
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
@@ -234,17 +234,15 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления клавиатуры: {e}")
|
||||
|
||||
banned_tg_ids = (
|
||||
select(BlockedUser.tg_id).union_all(
|
||||
select(ManualBan.tg_id).where(
|
||||
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow())
|
||||
)
|
||||
)
|
||||
banned_tg_ids = select(BlockedUser.tg_id).union_all(
|
||||
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
|
||||
)
|
||||
|
||||
query = None
|
||||
if send_to == "subscribed":
|
||||
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
|
||||
query = (
|
||||
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "unsubscribed":
|
||||
subquery = (
|
||||
select(User.tg_id)
|
||||
@@ -261,7 +259,11 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
|
||||
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
|
||||
elif send_to == "untrial":
|
||||
subquery = select(Key.tg_id)
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1])).where(~User.tg_id.in_(banned_tg_ids))
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "cluster":
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
@@ -281,7 +283,7 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
|
||||
)
|
||||
elif send_to == "trial":
|
||||
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
|
||||
query = (
|
||||
select(distinct(Key.tg_id))
|
||||
.where(Key.tariff_id.in_(trial_tariff_subquery))
|
||||
|
||||
@@ -29,6 +29,7 @@ from database import (
|
||||
sum_total_payments,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
from utils.csv_export import (
|
||||
export_hot_leads_csv,
|
||||
@@ -39,7 +40,7 @@ from utils.csv_export import (
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_stats_kb
|
||||
from hooks.hooks import run_hooks
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def build_tariff_arrangement_groups_kb(groups: list[str]) -> InlineKeyboardMarku
|
||||
if len(row) == 2 or i == len(groups) - 1:
|
||||
builder.row(*row)
|
||||
row = []
|
||||
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
@@ -80,9 +80,17 @@ def build_tariffs_arrangement_kb(group_code: str, tariffs: list) -> InlineKeyboa
|
||||
if grouped_tariffs.get(None):
|
||||
for t in grouped_tariffs[None]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬆️", callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack()),
|
||||
InlineKeyboardButton(text=f" {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack()),
|
||||
InlineKeyboardButton(text="⬇️", callback_data=AdminTariffCallback(action=f"quick_move_down|{t.get('id')}|{group_code}").pack())
|
||||
InlineKeyboardButton(
|
||||
text="⬆️",
|
||||
callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f" {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack()
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⬇️",
|
||||
callback_data=AdminTariffCallback(action=f"quick_move_down|{t.get('id')}|{group_code}").pack(),
|
||||
),
|
||||
)
|
||||
|
||||
for subgroup, tariffs_list in grouped_tariffs.items():
|
||||
@@ -92,11 +100,20 @@ def build_tariffs_arrangement_kb(group_code: str, tariffs: list) -> InlineKeyboa
|
||||
)
|
||||
for t in tariffs_list:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬆️", callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack()),
|
||||
InlineKeyboardButton(text=f" {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack()),
|
||||
InlineKeyboardButton(text="⬇️", callback_data=AdminTariffCallback(action=f"quick_move_down|{t.get('id')}|{group_code}").pack())
|
||||
InlineKeyboardButton(
|
||||
text="⬆️",
|
||||
callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f" {t.get('name')} ",
|
||||
callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⬇️",
|
||||
callback_data=AdminTariffCallback(action=f"quick_move_down|{t.get('id')}|{group_code}").pack(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
@@ -109,7 +126,7 @@ def build_tariffs_arrangement_kb(group_code: str, tariffs: list) -> InlineKeyboa
|
||||
callback_data=AdminPanelCallback(action="admin").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -150,16 +167,13 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
|
||||
grouped[subgroup].append(t)
|
||||
|
||||
sorted_subgroups = sorted(
|
||||
[k for k in grouped if k],
|
||||
key=lambda x: (sum(t.get("sort_order", 1) for t in grouped[x]), x)
|
||||
[k for k in grouped if k], key=lambda x: (sum(t.get("sort_order", 1) for t in grouped[x]), x)
|
||||
)
|
||||
|
||||
for subgroup_title in sorted_subgroups:
|
||||
subgroup_hash = create_subgroup_hash(subgroup_title, group_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{subgroup_title}", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
|
||||
)
|
||||
InlineKeyboardButton(text=f"{subgroup_title}", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")
|
||||
)
|
||||
|
||||
for t in grouped.get(None, []):
|
||||
@@ -216,7 +230,9 @@ def build_single_tariff_kb(tariff_id: int, group_code: str = None) -> InlineKeyb
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack() if group_code else AdminTariffCallback(action="list").pack(),
|
||||
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
|
||||
if group_code
|
||||
else AdminTariffCallback(action="list").pack(),
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -20,11 +21,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database import create_tariff
|
||||
from database.models import Gift, Key, Server, Tariff
|
||||
from database.tariffs import (
|
||||
create_subgroup_hash,
|
||||
create_subgroup_hash,
|
||||
find_subgroup_by_hash,
|
||||
get_tariffs,
|
||||
move_tariff_down as db_move_tariff_down,
|
||||
move_tariff_up as db_move_tariff_up,
|
||||
move_tariff_down as db_move_tariff_down
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
@@ -34,10 +35,10 @@ from .keyboard import (
|
||||
build_cancel_kb,
|
||||
build_edit_tariff_fields_kb,
|
||||
build_single_tariff_kb,
|
||||
build_tariff_arrangement_groups_kb,
|
||||
build_tariff_groups_kb,
|
||||
build_tariff_list_kb,
|
||||
build_tariff_menu_kb,
|
||||
build_tariff_arrangement_groups_kb,
|
||||
build_tariffs_arrangement_kb,
|
||||
)
|
||||
|
||||
@@ -348,7 +349,7 @@ async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTar
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
|
||||
return
|
||||
|
||||
|
||||
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -362,8 +363,8 @@ async def show_tariffs_arrangement(callback: CallbackQuery, callback_data: Admin
|
||||
group_code = callback_data.action.split("|")[1]
|
||||
|
||||
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
|
||||
tariffs = [t for t in tariffs_data['tariffs'] if t.get('is_active')]
|
||||
subgroup_weights = tariffs_data['subgroup_weights']
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ В этой группе пока нет активных тарифов.")
|
||||
@@ -373,21 +374,18 @@ async def show_tariffs_arrangement(callback: CallbackQuery, callback_data: Admin
|
||||
for t in tariffs:
|
||||
grouped_tariffs[t.get("subgroup_title")].append(t)
|
||||
|
||||
sorted_subgroups = sorted(
|
||||
[k for k in grouped_tariffs if k],
|
||||
key=lambda x: (subgroup_weights.get(x, 999999), x)
|
||||
)
|
||||
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
now = datetime.now(moscow_tz)
|
||||
current_time = now.strftime("%d.%m.%y %H:%M:%S МСК")
|
||||
|
||||
|
||||
text = f"🔢 <b>Итоговая сортировка тарифов в группе: {group_code}</b>\n\n"
|
||||
|
||||
if grouped_tariffs.get(None):
|
||||
text += "<b>📋 Основные тарифы:</b>\n"
|
||||
for t in grouped_tariffs[None]:
|
||||
sort_order = t.get('sort_order', 1)
|
||||
sort_order = t.get("sort_order", 1)
|
||||
text += f"• {t.get('name')} <code>[позиция: {sort_order}]</code>\n"
|
||||
text += "\n"
|
||||
|
||||
@@ -397,7 +395,7 @@ async def show_tariffs_arrangement(callback: CallbackQuery, callback_data: Admin
|
||||
subgroup_weight = subgroup_weights.get(subgroup, 999999)
|
||||
text += f"• <b>{subgroup}</b> <code>[вес группы: {subgroup_weight}]</code>\n"
|
||||
for t in grouped_tariffs[subgroup]:
|
||||
sort_order = t.get('sort_order', 1)
|
||||
sort_order = t.get("sort_order", 1)
|
||||
text += f" └ {t.get('name')} <code>[позиция: {sort_order}]</code>\n"
|
||||
text += "\n"
|
||||
|
||||
@@ -656,7 +654,7 @@ async def start_tariff_creation_existing_group(
|
||||
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||
traffic_text = f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit 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)
|
||||
|
||||
text = (
|
||||
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
|
||||
@@ -732,7 +730,9 @@ async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMCo
|
||||
for tariff in tariffs:
|
||||
is_selected = tariff.get("id") in selected
|
||||
prefix = "✅ " if is_selected else ""
|
||||
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('id')}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('id')}")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
|
||||
@@ -1018,7 +1018,11 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
|
||||
return
|
||||
|
||||
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
|
||||
all_tariffs_to_show = [t for t in all_tariffs_to_show if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""]
|
||||
all_tariffs_to_show = [
|
||||
t
|
||||
for t in all_tariffs_to_show
|
||||
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
|
||||
]
|
||||
|
||||
subgroup_tariff_ids = {t.get("id") for t in all_tariffs_to_show if t.get("subgroup_title") == subgroup_title}
|
||||
|
||||
@@ -1045,7 +1049,11 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
|
||||
for tariff in all_tariffs_to_show:
|
||||
is_in_subgroup = tariff.get("id") in subgroup_tariff_ids
|
||||
prefix = "✅ " if is_in_subgroup else ""
|
||||
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
|
||||
@@ -1078,13 +1086,21 @@ async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMCont
|
||||
subgroup_hash = data["subgroup_hash"]
|
||||
|
||||
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
|
||||
all_tariffs_to_show = [t for t in all_tariffs_to_show if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""]
|
||||
all_tariffs_to_show = [
|
||||
t
|
||||
for t in all_tariffs_to_show
|
||||
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
|
||||
]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for tariff in all_tariffs_to_show:
|
||||
is_selected = tariff.get("id") in selected_ids
|
||||
prefix = "✅ " if is_selected else ""
|
||||
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
|
||||
@@ -1183,14 +1199,14 @@ async def move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCall
|
||||
tariff_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
success = await db_move_tariff_up(session, tariff_id)
|
||||
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not tariff:
|
||||
await callback.answer("❌ Тариф не найден", show_alert=True)
|
||||
return
|
||||
@@ -1205,14 +1221,14 @@ async def move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCa
|
||||
tariff_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
success = await db_move_tariff_down(session, tariff_id)
|
||||
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not tariff:
|
||||
await callback.answer("❌ Тариф не найден", show_alert=True)
|
||||
return
|
||||
@@ -1229,7 +1245,7 @@ async def quick_move_tariff_up(callback: CallbackQuery, callback_data: AdminTari
|
||||
group_code = parts[2]
|
||||
|
||||
success = await db_move_tariff_up(session, tariff_id)
|
||||
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||
return
|
||||
@@ -1238,6 +1254,7 @@ async def quick_move_tariff_up(callback: CallbackQuery, callback_data: AdminTari
|
||||
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
|
||||
await show_tariffs_arrangement(callback, new_callback_data, session)
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_down|")), IsAdminFilter())
|
||||
async def quick_move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
|
||||
parts = callback_data.action.split("|")
|
||||
@@ -1245,7 +1262,7 @@ async def quick_move_tariff_down(callback: CallbackQuery, callback_data: AdminTa
|
||||
group_code = parts[2]
|
||||
|
||||
success = await db_move_tariff_down(session, tariff_id)
|
||||
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -10,8 +10,8 @@ from aiogram import F, Router, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.utils.formatting import Text, Bold, BlockQuote
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.formatting import BlockQuote, Bold, Text
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -549,39 +549,35 @@ async def handle_key_edit(
|
||||
update: bool = False,
|
||||
):
|
||||
email = callback_data.data
|
||||
key_details = await get_key_details(session, email)
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
key_obj: Key | None = result.scalar_one_or_none()
|
||||
|
||||
if not key_details:
|
||||
if not key_obj:
|
||||
await callback_query.message.edit_text(
|
||||
text="🚫 Информация о ключе не найдена.",
|
||||
reply_markup=build_editor_kb(callback_data.tg_id),
|
||||
)
|
||||
return
|
||||
|
||||
key_value = key_details.get("key") or key_details.get("remnawave_link") or "—"
|
||||
alias = key_details.get("alias")
|
||||
key_value = key_obj.key or key_obj.remnawave_link or "—"
|
||||
alias_part = f" (<i>{key_obj.alias}</i>)" if key_obj.alias else ""
|
||||
|
||||
created_at_raw = key_details.get("created_at")
|
||||
if created_at_raw:
|
||||
created_at_dt = datetime.fromtimestamp(int(created_at_raw) / 1000) + timedelta(hours=3)
|
||||
if key_obj.created_at:
|
||||
created_at_dt = datetime.fromtimestamp(int(key_obj.created_at) / 1000) + timedelta(hours=3)
|
||||
created_at = created_at_dt.strftime("%d %B %Y года %H:%M")
|
||||
else:
|
||||
created_at = "—"
|
||||
|
||||
|
||||
expiry_time_raw = key_details.get("expiry_time")
|
||||
if expiry_time_raw:
|
||||
expiry_dt = datetime.fromtimestamp(int(expiry_time_raw) / 1000)
|
||||
if key_obj.expiry_time:
|
||||
expiry_dt = datetime.fromtimestamp(int(key_obj.expiry_time) / 1000)
|
||||
expiry_date = expiry_dt.strftime("%d %B %Y года %H:%M")
|
||||
else:
|
||||
expiry_date = "—"
|
||||
|
||||
tariff_name = "—"
|
||||
subgroup_title = "—"
|
||||
if key_details.get("tariff_id"):
|
||||
result = await session.execute(
|
||||
select(Tariff.name, Tariff.subgroup_title).where(Tariff.id == key_details["tariff_id"])
|
||||
)
|
||||
if key_obj.tariff_id:
|
||||
result = await session.execute(select(Tariff.name, Tariff.subgroup_title).where(Tariff.id == key_obj.tariff_id))
|
||||
row = result.first()
|
||||
if row:
|
||||
tariff_name = row[0]
|
||||
@@ -590,20 +586,18 @@ async def handle_key_edit(
|
||||
text = (
|
||||
"<b>🔑 Информация о подписке</b>\n\n"
|
||||
"<blockquote>"
|
||||
f"🔗 <b>Ключ:</b> <code>{key_value}</code>\n"
|
||||
f"🔗 <b>Ключ{alias_part}:</b> <code> {key_value}</code>\n"
|
||||
f"📆 <b>Создан:</b> {created_at} (МСК)\n"
|
||||
f"⏰ <b>Истекает:</b> {expiry_date} (МСК)\n"
|
||||
f"🌐 <b>Кластер:</b> {key_details.get('cluster_name', '—')}\n"
|
||||
f"🆔 <b>ID клиента:</b> {key_details.get('tg_id', '—')}\n"
|
||||
f"🌐 <b>Кластер:</b> {key_obj.server_id or '—'}\n"
|
||||
f"🆔 <b>ID клиента:</b> {key_obj.tg_id or '—'}\n"
|
||||
f"📁 <b>Группа:</b> {subgroup_title}\n"
|
||||
f"📦 <b>Тариф:</b> {tariff_name}\n"
|
||||
"</blockquote>"
|
||||
)
|
||||
if alias:
|
||||
text += f"🏷️ <b>Имя подписки:</b> {alias}\n"
|
||||
text += "</blockquote>"
|
||||
|
||||
if not update or not callback_data.edit:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_obj.__dict__, email))
|
||||
else:
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
@@ -1163,7 +1157,7 @@ async def process_user_search(
|
||||
result_referrer = await session.execute(stmt_referrer)
|
||||
ref_username = result_referrer.scalar_one_or_none()
|
||||
if ref_username:
|
||||
referrer_text = f"🤝 Пригласил: @{ref_username}"
|
||||
referrer_text = f"🤝 Пригласил: @{ref_username} ({referrer_tg_id})"
|
||||
else:
|
||||
referrer_text = f"🤝 Пригласил: {referrer_tg_id}"
|
||||
|
||||
@@ -1201,29 +1195,18 @@ async def process_user_search(
|
||||
if referrer_text:
|
||||
body += Text(referrer_text, "\n")
|
||||
|
||||
text_builder = Text(
|
||||
Bold("📊 Информация о пользователе"), "\n\n",
|
||||
BlockQuote(body)
|
||||
)
|
||||
text_builder = Text(Bold("📊 Информация о пользователе"), "\n\n", BlockQuote(body))
|
||||
|
||||
text = text_builder.as_html()
|
||||
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned)
|
||||
|
||||
if edit:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text=text,
|
||||
reply_markup=kb,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
await message.edit_text(text=text, reply_markup=kb, disable_web_page_preview=True)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
else:
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=kb,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb, disable_web_page_preview=True)
|
||||
|
||||
|
||||
async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession) -> Exception | None:
|
||||
|
||||
+9
-6
@@ -10,7 +10,7 @@ CANCEL = "❌ Отмена"
|
||||
|
||||
# Профиль
|
||||
|
||||
ADD_SUB = "➕ Добавить новую подписку"
|
||||
ADD_SUB = "Купить новую подписку"
|
||||
MY_SUBS = "📱 Мои подписки"
|
||||
BALANCE = "💵 Баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
@@ -37,6 +37,7 @@ DISCOUNT_TARIFF = "🔥 Получить скидку"
|
||||
MAX_DISCOUNT_TARIFF = "⚡ Получить максимальную скидку"
|
||||
|
||||
# Кнопки подписки на канал
|
||||
|
||||
SUB_CHANELL = "📢 Подписаться"
|
||||
SUB_CHANELL_DONE = "✅ Я подписался"
|
||||
|
||||
@@ -57,7 +58,7 @@ DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
|
||||
IMPORT_IOS = "🍏 Подключить"
|
||||
IMPORT_ANDROID = "🤖 Подключить"
|
||||
PC_BUTTON = "💻 Компьютеры"
|
||||
TV_BUTTON = "📺 Андроид TV"
|
||||
TV_BUTTON = "📺 Подключить Андроид TV"
|
||||
CONNECT_PHONE = "📱 Подключить телефон"
|
||||
CONNECT_DEVICE = "📲 Подключить устройство"
|
||||
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
|
||||
@@ -65,8 +66,6 @@ CONNECT_MACOS_BUTTON = "🍏 Подключить"
|
||||
ALIAS = "✏️"
|
||||
UNFREEZE = "🟢 Разморозить подписку"
|
||||
FREEZE = "🛑 Заморозить подписку"
|
||||
RENEW = "⏳ Продлить"
|
||||
RENEW_FULL = "⏳ Продлить подписку"
|
||||
DELETE = "❌ Удалить"
|
||||
CHANGE_LOCATION = "🌍 Сменить локацию"
|
||||
QR = "📷 Показать QR-код"
|
||||
@@ -78,7 +77,7 @@ PC_MACOS = "🍏 macOS"
|
||||
TV = "📺 Телевизор"
|
||||
ROUTER = "📶 Роутер"
|
||||
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
|
||||
RENEW_KEY = "Купить VPN 🔄 Продлить VPN"
|
||||
RENEW_KEY = "⏳ Продлить подписку"
|
||||
RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку"
|
||||
TV_CONTINUE = "▶ Продолжить"
|
||||
TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||
@@ -91,12 +90,16 @@ PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
|
||||
STARS_BOT = "🤖 Бот для покупки звезд"
|
||||
WATA = "💳 WATA"
|
||||
WATA_RU = "🇷🇺 WATA: Карты РФ"
|
||||
WATA_SBP = "🏦 WATA: СБП"
|
||||
WATA_INT = "🌍 WATA: Международные карты"
|
||||
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
|
||||
KASSAI_SBP = "🏦 KassaAI: СБП"
|
||||
KASSAI = "💳 KassaAI"
|
||||
TRIBUTE = "💳 Tribute"
|
||||
RUB_CURRENCY = "₽ Рубли (RUB)"
|
||||
USD_CURRENCY = "$ USD / Cryptowallet"
|
||||
|
||||
# Кнопки Heleket
|
||||
HELEKET_CRYPTO = "₿ Heleket: Crypto"
|
||||
HELEKET = "Heleket Crypto"
|
||||
|
||||
+4
-1
@@ -29,6 +29,7 @@ from database import (
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import (
|
||||
COUPONS_DAYS_MESSAGE,
|
||||
@@ -98,6 +99,7 @@ async def activate_coupon(
|
||||
return
|
||||
|
||||
user = user_data or message.from_user or message.chat
|
||||
language_code = user.get("language_code") if isinstance(user, dict) else getattr(user, "language_code", None)
|
||||
user_id = user["tg_id"] if isinstance(user, dict) else user.id
|
||||
|
||||
usage = await check_coupon_usage(session, coupon.id, user_id)
|
||||
@@ -127,7 +129,8 @@ async def activate_coupon(
|
||||
await update_coupon_usage_count(session, coupon.id)
|
||||
await create_coupon_usage(session, coupon.id, user_id)
|
||||
await add_payment(session, tg_id=user_id, amount=coupon.amount, payment_system="coupon")
|
||||
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей.")
|
||||
amount_txt = await format_for_user(session, user_id, coupon.amount, language_code)
|
||||
await message.answer(f"✅ Купон активирован, на баланс начислено {amount_txt}.")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при активации купона на баланс: {e}")
|
||||
|
||||
@@ -3,10 +3,10 @@ from aiogram.types import InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import SUPPORT_CHAT_URL
|
||||
from database import async_session_maker
|
||||
from handlers.buttons import MAIN_MENU, SUPPORT
|
||||
from handlers.texts import FALLBACK_MESSAGE
|
||||
from hooks.hooks import run_hooks
|
||||
from database import async_session_maker
|
||||
|
||||
|
||||
fallback_router = Router()
|
||||
@@ -23,9 +23,9 @@ async def handle_unhandled_messages(message: Message):
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
session=session,
|
||||
message=message
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
@@ -38,8 +38,8 @@ from handlers.texts import (
|
||||
SUBSCRIPTION_DESCRIPTION,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from hooks.hooks import run_hooks
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -63,10 +63,12 @@ async def handle_connect_device(callback_query: CallbackQuery, session: AsyncSes
|
||||
hook_builder = InlineKeyboardBuilder()
|
||||
hook_builder.attach(builder)
|
||||
|
||||
hook_commands = await run_hooks("connect_device_menu", chat_id=callback_query.from_user.id, admin=False, session=session)
|
||||
hook_commands = await run_hooks(
|
||||
"connect_device_menu", chat_id=callback_query.from_user.id, admin=False, session=session
|
||||
)
|
||||
if hook_commands:
|
||||
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
|
||||
|
||||
|
||||
final_markup = hook_builder.as_markup()
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONNECT_DEVICE] Ошибка при применении хуков: {e}")
|
||||
|
||||
@@ -40,8 +40,8 @@ from handlers.utils import (
|
||||
get_least_loaded_cluster,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from hooks.hooks import run_hooks
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -173,7 +173,9 @@ async def key_cluster_mode(
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
try:
|
||||
hook_commands = await run_hooks("key_creation_complete", chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name)
|
||||
hook_commands = await run_hooks(
|
||||
"key_creation_complete", chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name
|
||||
)
|
||||
if hook_commands:
|
||||
builder = insert_hook_buttons(builder, hook_commands)
|
||||
except Exception as e:
|
||||
|
||||
@@ -44,11 +44,11 @@ from handlers.utils import (
|
||||
get_least_loaded_cluster,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from hooks.hooks import run_hooks
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
from panels._3xui import delete_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import delete_client, get_xui_instance
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -513,7 +513,9 @@ async def finalize_key_creation(
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
try:
|
||||
hook_commands = await run_hooks("key_creation_complete", chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name)
|
||||
hook_commands = await run_hooks(
|
||||
"key_creation_complete", chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name
|
||||
)
|
||||
if hook_commands:
|
||||
builder = insert_hook_buttons(builder, hook_commands)
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,39 +12,34 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import (
|
||||
DISCOUNT_ACTIVE_HOURS,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
DISCOUNT_ACTIVE_HOURS,
|
||||
)
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_tariff_by_id,
|
||||
get_tariffs_for_cluster,
|
||||
get_trial,
|
||||
)
|
||||
from database.notifications import check_hot_lead_discount
|
||||
from database.models import Admin
|
||||
from database.notifications import check_hot_lead_discount
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
||||
from handlers.admin.panel.keyboard import AdminPanelCallback
|
||||
from handlers.buttons import MAIN_MENU, PAYMENT
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||
from handlers.payments.wata import handle_custom_amount_input as handle_custom_amount_input_wata
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.texts import (
|
||||
CREATING_CONNECTION_MSG,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
SELECT_TARIFF_PLAN_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, get_least_loaded_cluster, format_discount_time_left
|
||||
from handlers.utils import edit_or_send_message, format_discount_time_left, get_least_loaded_cluster
|
||||
from logger import logger
|
||||
from utils.modules_loader import load_module_fast_flow_handlers
|
||||
|
||||
from .key_cluster_mode import key_cluster_mode
|
||||
from .key_country_mode import key_country_mode
|
||||
@@ -144,6 +139,12 @@ async def handle_key_creation(
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
|
||||
language_code = (
|
||||
message_or_query.from_user.language_code
|
||||
if not isinstance(message_or_query, CallbackQuery)
|
||||
else message_or_query.from_user.language_code
|
||||
)
|
||||
|
||||
discount_info = None
|
||||
subgroup_weights = {}
|
||||
|
||||
@@ -151,17 +152,18 @@ async def handle_key_creation(
|
||||
group_code = tariffs[0].get("group_code")
|
||||
if group_code:
|
||||
from database.notifications import check_hot_lead_discount
|
||||
|
||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||
|
||||
|
||||
if discount_info and discount_info.get("available"):
|
||||
group_code = discount_info["tariff_group"]
|
||||
await state.update_data(discount_info=discount_info)
|
||||
else:
|
||||
await state.update_data(discount_info=None)
|
||||
|
||||
|
||||
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
|
||||
tariffs = [t for t in tariffs_data['tariffs'] if t.get('is_active')]
|
||||
subgroup_weights = tariffs_data['subgroup_weights']
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||
|
||||
if not tariffs:
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
@@ -216,18 +218,19 @@ async def handle_key_creation(
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
text=f"{t['name']} — {price_txt}",
|
||||
callback_data=f"select_tariff_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted(
|
||||
[k for k in grouped_tariffs if k],
|
||||
key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x)
|
||||
key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x),
|
||||
)
|
||||
|
||||
|
||||
for subgroup in sorted_subgroups:
|
||||
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
||||
builder.row(
|
||||
@@ -242,16 +245,16 @@ async def handle_key_creation(
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
|
||||
discount_message = ""
|
||||
|
||||
|
||||
if discount_info and discount_info.get("available"):
|
||||
discount_message = f"\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
|
||||
discount_message = "\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
|
||||
if discount_info["type"] == "hot_lead_step_2":
|
||||
discount_message += "💎 <b>Вам открыт доступ к специальным тарифам</b>\n"
|
||||
discount_message += "🚀 <b>Эксклюзивные предложения</b> - доступны только для вас!\n"
|
||||
else:
|
||||
discount_message += "💎 <b>Вам открыт доступ к МАКСИМАЛЬНО выгодным тарифам</b>\n"
|
||||
discount_message += "🚀 <b>VIP предложения</b> - максимальная выгода!\n"
|
||||
|
||||
|
||||
expires_at = discount_info["expires_at"]
|
||||
discount_message += f"</blockquote>\n⏰ <b>Предложение действует только: {format_discount_time_left(expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS)}</b>, не упустите свой шанс!"
|
||||
|
||||
@@ -291,7 +294,7 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
|
||||
group_code = tariffs[0].get("group_code")
|
||||
if group_code:
|
||||
tariffs = await get_tariffs(session, group_code=group_code)
|
||||
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get('is_active')]
|
||||
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
|
||||
|
||||
if not filtered:
|
||||
await edit_or_send_message(
|
||||
@@ -301,11 +304,15 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
language_code = callback.from_user.language_code
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
text=f"{t['name']} — {price_txt}",
|
||||
callback_data=f"select_tariff_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
@@ -365,49 +372,35 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
|
||||
|
||||
if balance < price_rub:
|
||||
required_amount = ceil(price_rub - balance)
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"tariff_id": tariff_id,
|
||||
"duration_days": duration_days,
|
||||
"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,
|
||||
"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(),
|
||||
)
|
||||
|
||||
module_fast_flow_handlers = load_module_fast_flow_handlers()
|
||||
flow_handled = False
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW in module_fast_flow_handlers:
|
||||
try:
|
||||
handler = module_fast_flow_handlers[USE_NEW_PAYMENT_FLOW]
|
||||
await handler(callback_query, session, state)
|
||||
flow_handled = True
|
||||
except Exception as e:
|
||||
logger.error(f"[CREATE] Ошибка в модульном обработчике быстрого флоу {USE_NEW_PAYMENT_FLOW}: {e}")
|
||||
|
||||
if not flow_handled:
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(message=callback_query, session=session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||
await process_custom_amount_input_stars(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
|
||||
await process_custom_amount_input_yoomoney(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "WATA":
|
||||
await state.update_data(wata_cassa="sbp", required_amount=required_amount)
|
||||
await handle_custom_amount_input_wata(callback_query, state)
|
||||
else:
|
||||
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),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
@@ -7,12 +7,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DISCOUNT_ACTIVE_HOURS
|
||||
from database import get_tariffs, get_keys
|
||||
from database import get_keys, get_tariffs
|
||||
from database.models import Notification
|
||||
from handlers.utils import format_discount_time_left
|
||||
from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION
|
||||
from handlers.notifications.notify_kb import build_tariffs_keyboard
|
||||
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
|
||||
from handlers.buttons import RENEW_KEY_NOTIFICATION, MAIN_MENU
|
||||
from handlers.utils import format_discount_time_left
|
||||
from logger import logger
|
||||
|
||||
from .key_create import select_tariff_plan
|
||||
@@ -43,15 +43,12 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
||||
return
|
||||
|
||||
keys = await get_keys(session, tg_id)
|
||||
|
||||
|
||||
if keys and len(keys) > 0:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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"))
|
||||
|
||||
|
||||
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
|
||||
await callback.message.edit_text(
|
||||
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
@@ -59,7 +56,7 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
||||
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
|
||||
f"</blockquote>\n"
|
||||
f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, DISCOUNT_ACTIVE_HOURS)} — не упустите свой шанс!</b>",
|
||||
reply_markup=builder.as_markup()
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
tariffs = await get_tariffs(session=session, group_code="discounts")
|
||||
@@ -113,22 +110,19 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
||||
return
|
||||
|
||||
keys = await get_keys(session, tg_id)
|
||||
|
||||
|
||||
if keys and len(keys) > 0:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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"))
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🎯 <b>УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
|
||||
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
|
||||
f"</blockquote>\n"
|
||||
f"⏰ <b>Время ограничено: {format_discount_time_left(last_time, DISCOUNT_ACTIVE_HOURS)} — не упустите шанс!</b>",
|
||||
reply_markup=builder.as_markup()
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
tariffs = await get_tariffs(session, group_code="discounts_max")
|
||||
|
||||
+74
-85
@@ -13,10 +13,9 @@ from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import USE_NEW_PAYMENT_FLOW, DISCOUNT_ACTIVE_HOURS
|
||||
from config import DISCOUNT_ACTIVE_HOURS, USE_NEW_PAYMENT_FLOW
|
||||
from database import (
|
||||
check_tariff_exists,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
@@ -30,22 +29,18 @@ from database.notifications import check_hot_lead_discount
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
||||
from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||
from handlers.payments.wata import handle_custom_amount_input as handle_custom_amount_input_wata
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.texts import (
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
KEY_NOT_FOUND_MSG,
|
||||
PLAN_SELECTION_MSG,
|
||||
get_renewal_message,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, get_russian_month, format_discount_time_left
|
||||
from hooks.hooks import run_hooks
|
||||
from handlers.utils import edit_or_send_message, format_discount_time_left, get_russian_month
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
from utils.modules_loader import load_module_fast_flow_handlers
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -101,14 +96,14 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
group_code = current_tariff["group_code"]
|
||||
|
||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||
|
||||
|
||||
if discount_info.get("available"):
|
||||
group_code = discount_info["tariff_group"]
|
||||
|
||||
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
|
||||
tariffs = [t for t in tariffs_data['tariffs'] if t.get('is_active')]
|
||||
subgroup_weights = tariffs_data['subgroup_weights']
|
||||
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||
|
||||
if not tariffs:
|
||||
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
|
||||
return
|
||||
@@ -120,19 +115,19 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
price_text = await format_for_user(session, tg_id, t["price_rub"], language_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
text=f"{t['name']} — {price_text}",
|
||||
callback_data=f"renew_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted(
|
||||
[k for k in grouped_tariffs if k],
|
||||
key=lambda x: (subgroup_weights.get(x, 999999), x)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
|
||||
|
||||
for subgroup in sorted_subgroups:
|
||||
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
||||
builder.row(
|
||||
@@ -142,7 +137,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="renew_menu"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
|
||||
try:
|
||||
hook_builder = InlineKeyboardBuilder()
|
||||
@@ -151,31 +146,35 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
hook_commands = await run_hooks("renew_tariffs", chat_id=tg_id, admin=False, session=session)
|
||||
if hook_commands:
|
||||
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
|
||||
|
||||
|
||||
final_markup = hook_builder.as_markup()
|
||||
except Exception as e:
|
||||
logger.warning(f"[RENEW] Ошибка при применении хуков: {e}")
|
||||
final_markup = builder.as_markup()
|
||||
|
||||
balance = await get_balance(session, tg_id)
|
||||
balance_rub = await get_balance(session, tg_id) or 0
|
||||
balance = await format_for_user(session, tg_id, balance_rub, language_code)
|
||||
|
||||
discount_message = ""
|
||||
if discount_info.get("available"):
|
||||
discount_message = f"\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
|
||||
discount_message = "\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
|
||||
if discount_info["type"] == "hot_lead_step_2":
|
||||
discount_message += "💎 <b>Вам открыт доступ к специальным тарифам</b> для продления\n"
|
||||
discount_message += "🚀 <b>Эксклюзивные предложения</b> - доступны только для вас!\n"
|
||||
else:
|
||||
discount_message += "💎 <b>Вам открыт доступ к МАКСИМАЛЬНО выгодным тарифам</b> для продления\n"
|
||||
discount_message += "🚀 <b>VIP предложения</b> - максимальная выгода!\n"
|
||||
|
||||
|
||||
expires_at = discount_info["expires_at"]
|
||||
discount_message += f"</blockquote>\n⏰ <b>Предложение действует только: {format_discount_time_left(expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS)}, не упустите свой шанс!</b>"
|
||||
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
) + discount_message
|
||||
response_message = (
|
||||
PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
+ discount_message
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -230,8 +229,9 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
group_code = row[0]
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
language_code = callback.from_user.language_code
|
||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||
|
||||
|
||||
if discount_info.get("available"):
|
||||
group_code = discount_info["tariff_group"]
|
||||
|
||||
@@ -253,9 +253,10 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
text=f"{t['name']} — {price_txt}",
|
||||
callback_data=f"renew_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
@@ -267,10 +268,12 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
hook_builder = InlineKeyboardBuilder()
|
||||
hook_builder.attach(builder)
|
||||
|
||||
hook_commands = await run_hooks("renew_tariffs", chat_id=callback.from_user.id, admin=False, session=session)
|
||||
hook_commands = await run_hooks(
|
||||
"renew_tariffs", chat_id=callback.from_user.id, admin=False, session=session
|
||||
)
|
||||
if hook_commands:
|
||||
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
|
||||
|
||||
|
||||
final_markup = hook_builder.as_markup()
|
||||
except Exception as e:
|
||||
logger.warning(f"[RENEW_SUBGROUP] Ошибка при применении хуков: {e}")
|
||||
@@ -278,14 +281,14 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
|
||||
discount_message = ""
|
||||
if discount_info.get("available"):
|
||||
discount_message = f"\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
|
||||
discount_message = "\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
|
||||
if discount_info["type"] == "hot_lead_step_2":
|
||||
discount_message += "💎 <b>Вам открыт доступ к специальным тарифам</b> для продления\n"
|
||||
discount_message += "🚀 <b>Эксклюзивные предложения</b> - доступны только для вас!\n"
|
||||
else:
|
||||
discount_message += "💎 <b>Вам открыт доступ к МАКСИМАЛЬНО выгодным тарифам</b> для продления\n"
|
||||
discount_message += "🚀 <b>VIP предложения</b> - максимальная выгода!\n"
|
||||
|
||||
|
||||
expires_at = discount_info["expires_at"]
|
||||
discount_message += f"</blockquote>\n⏰ <b>Предложение действует только: {format_discount_time_left(expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS)}, не упустите свой шанс!</b>"
|
||||
|
||||
@@ -325,10 +328,9 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
|
||||
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 callback_query.message.answer(
|
||||
"❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
|
||||
reply_markup=builder.as_markup()
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -353,57 +355,42 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
|
||||
|
||||
balance = round(await get_balance(session, tg_id), 2)
|
||||
cost = round(cost, 2)
|
||||
|
||||
if balance < cost:
|
||||
required_amount = ceil(cost - balance)
|
||||
logger.info(f"[RENEW] Недостаточно средств: {required_amount}₽")
|
||||
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"tariff_id": tariff_id,
|
||||
"client_id": client_id,
|
||||
"cost": cost,
|
||||
"required_amount": required_amount,
|
||||
"new_expiry_time": new_expiry_time,
|
||||
"total_gb": total_gb,
|
||||
"email": email,
|
||||
},
|
||||
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": tariff_id,
|
||||
"client_id": client_id,
|
||||
"cost": cost,
|
||||
"required_amount": required_amount,
|
||||
"new_expiry_time": new_expiry_time,
|
||||
"total_gb": total_gb,
|
||||
"email": email,
|
||||
},
|
||||
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(),
|
||||
)
|
||||
|
||||
module_fast_flow_handlers = load_module_fast_flow_handlers()
|
||||
flow_handled = False
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW in module_fast_flow_handlers:
|
||||
try:
|
||||
handler = module_fast_flow_handlers[USE_NEW_PAYMENT_FLOW]
|
||||
await handler(callback_query, session, state)
|
||||
flow_handled = True
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW] Ошибка в модульном обработчике быстрого флоу {USE_NEW_PAYMENT_FLOW}: {e}")
|
||||
|
||||
if not flow_handled:
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(message=callback_query, session=session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||
await process_custom_amount_input_stars(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
|
||||
await process_custom_amount_input_yoomoney(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "WATA":
|
||||
await state.update_data(wata_cassa="sbp", required_amount=required_amount)
|
||||
await handle_custom_amount_input_wata(callback_query, state)
|
||||
else:
|
||||
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),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней")
|
||||
@@ -457,7 +444,9 @@ async def complete_key_renewal(
|
||||
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
|
||||
|
||||
try:
|
||||
hook_commands = await run_hooks("renewal_complete", chat_id=tg_id, admin=False, session=session, email=email, client_id=client_id)
|
||||
hook_commands = await run_hooks(
|
||||
"renewal_complete", chat_id=tg_id, admin=False, session=session, email=email, client_id=client_id
|
||||
)
|
||||
if hook_commands:
|
||||
builder = insert_hook_buttons(builder, hook_commands)
|
||||
except Exception as e:
|
||||
|
||||
+49
-108
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
@@ -21,13 +22,15 @@ from config import (
|
||||
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
|
||||
HWID_RESET_BUTTON,
|
||||
QRCODE,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
RENEW_BUTTON_BEFORE_DAYS,
|
||||
TOGGLE_CLIENT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
|
||||
from database.models import Key
|
||||
from handlers.buttons import (
|
||||
ADD_SUB,
|
||||
ALIAS,
|
||||
BACK,
|
||||
CHANGE_LOCATION,
|
||||
@@ -39,6 +42,7 @@ from handlers.buttons import (
|
||||
MAIN_MENU,
|
||||
PC_BUTTON,
|
||||
QR,
|
||||
RENEW_KEY,
|
||||
RENEW_SUB,
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
@@ -50,7 +54,6 @@ from handlers.texts import (
|
||||
KEYS_HEADER,
|
||||
NO_SUBSCRIPTIONS_MSG,
|
||||
RENAME_KEY_PROMPT,
|
||||
SELECT_SUBS,
|
||||
key_message,
|
||||
)
|
||||
from handlers.utils import (
|
||||
@@ -61,9 +64,10 @@ from handlers.utils import (
|
||||
get_russian_month,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
from hooks.hooks import run_hooks
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -85,6 +89,13 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa
|
||||
|
||||
try:
|
||||
records = await get_keys(session, tg_id)
|
||||
|
||||
if records and len(records) == 1:
|
||||
key_name = records[0].email
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
await render_key_info(target_message, session, key_name, image_path)
|
||||
return
|
||||
|
||||
inline_keyboard, response_message = build_keys_response(records)
|
||||
image_path = os.path.join("img", "pic_keys.jpg")
|
||||
|
||||
@@ -200,16 +211,12 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
|
||||
|
||||
async def render_key_info(message: Message, session: Any, key_name: str, image_path: str):
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await message.answer("<b>Информация о подписке не найдена.</b>")
|
||||
return
|
||||
|
||||
is_frozen = record["is_frozen"]
|
||||
record["email"]
|
||||
client_id = record.get("client_id")
|
||||
remnawave_link = record.get("remnawave_link")
|
||||
key = record.get("key")
|
||||
@@ -232,7 +239,9 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
time_left = expiry_date - datetime.utcnow()
|
||||
now = datetime.utcnow()
|
||||
time_left = expiry_date - now
|
||||
show_renew_btn = time_left.total_seconds() <= RENEW_BUTTON_BEFORE_DAYS * 86400
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = DAYS_LEFT_MESSAGE
|
||||
@@ -249,14 +258,20 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
is_full_remnawave = await is_full_task
|
||||
tariff = await tariff_task if tariff_task else None
|
||||
|
||||
hwid_count = 0
|
||||
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
|
||||
if is_full_remnawave and client_id:
|
||||
try:
|
||||
servers = await get_servers(session)
|
||||
remna_server = next(
|
||||
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
|
||||
None,
|
||||
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None
|
||||
)
|
||||
if remna_server:
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
@@ -270,14 +285,11 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
traffic_limit = 0
|
||||
device_limit = 0
|
||||
subgroup_title = ""
|
||||
tariff = None
|
||||
if record.get("tariff_id"):
|
||||
tariff = await get_tariff_by_id(session, record["tariff_id"])
|
||||
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", "")
|
||||
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", "")
|
||||
|
||||
tariff_duration = tariff_name
|
||||
|
||||
@@ -295,12 +307,7 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
)
|
||||
|
||||
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=RENEW_SUB,
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=RENEW_SUB, callback_data=f"update_subscription|{key_name}"))
|
||||
|
||||
if is_full_remnawave and final_link:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||
@@ -314,21 +321,18 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
|
||||
if show_renew_btn:
|
||||
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if HWID_RESET_BUTTON and hwid_count > 0:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=HWID_BUTTON,
|
||||
callback_data=f"reset_hwid|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=HWID_BUTTON, callback_data=f"reset_hwid|{key_name}"))
|
||||
|
||||
if QRCODE:
|
||||
builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}"))
|
||||
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
|
||||
@@ -336,10 +340,7 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
if TOGGLE_CLIENT:
|
||||
builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
|
||||
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
module_buttons = await run_hooks("view_key_menu", key_name=key_name, session=session)
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
@@ -353,11 +354,12 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
|
||||
@router.callback_query(F.data.startswith("reset_hwid|"))
|
||||
async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(session, key_name)
|
||||
|
||||
record_task = asyncio.create_task(get_key_details(session, key_name))
|
||||
servers_task = asyncio.create_task(get_servers(session=session))
|
||||
|
||||
record = await record_task
|
||||
if not record:
|
||||
await callback_query.answer("❌ Ключ не найден.", show_alert=True)
|
||||
return
|
||||
@@ -367,11 +369,8 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
await callback_query.answer("❌ У ключа отсутствует client_id.", show_alert=True)
|
||||
return
|
||||
|
||||
servers = await get_servers(session=session)
|
||||
remna_server = next(
|
||||
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
|
||||
None,
|
||||
)
|
||||
servers = await servers_task
|
||||
remna_server = next((srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None)
|
||||
if not remna_server:
|
||||
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
|
||||
return
|
||||
@@ -391,12 +390,12 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
deleted += 1
|
||||
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
|
||||
|
||||
hook_result = await run_hooks("after_hwid_reset", chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name)
|
||||
hook_result = await run_hooks(
|
||||
"after_hwid_reset", chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name
|
||||
)
|
||||
if hook_result and any("redirect_to_profile" in str(result) for result in hook_result):
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if callback_query.message.text:
|
||||
await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=kb.as_markup())
|
||||
else:
|
||||
@@ -405,61 +404,3 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
await render_key_info(callback_query.message, session, key_name, image_path)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "renew_menu")
|
||||
@router.callback_query(F.data == "extend")
|
||||
@router.message(F.text == "/extend")
|
||||
async def process_renew_menu(callback_query_or_message: CallbackQuery | Message, session: Any):
|
||||
try:
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
tg_id = callback_query_or_message.from_user.id
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
tg_id = callback_query_or_message.from_user.id
|
||||
|
||||
records = await get_keys(session, tg_id)
|
||||
servers_dict = await get_servers(session)
|
||||
all_server_names = set()
|
||||
for servers in servers_dict.values():
|
||||
for s in servers:
|
||||
all_server_names.add(s["server_name"])
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
if records:
|
||||
for record in records:
|
||||
if getattr(record, "is_frozen", False):
|
||||
continue
|
||||
alias = record.alias
|
||||
email = record.email
|
||||
expiry_time = record.expiry_time
|
||||
server_id = record.server_id
|
||||
key_display = alias.strip() if alias else email
|
||||
|
||||
if expiry_time:
|
||||
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
now = datetime.now(moscow_tz)
|
||||
days_left = (expiry_date_full - now).days
|
||||
if (expiry_date_full - now).total_seconds() <= 0:
|
||||
days_text = "🔴 Истекла"
|
||||
else:
|
||||
days_text = format_days(days_left)
|
||||
else:
|
||||
days_text = "истекла"
|
||||
server_info = f" ({server_id})" if server_id in all_server_names else ""
|
||||
btn_text = f"🔑 {key_display} (⏳{days_text}) {server_info}"
|
||||
builder.row(InlineKeyboardButton(text=btn_text, callback_data=f"renew_key|{email}"))
|
||||
text = SELECT_SUBS
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении подписок для продления: {e}"
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
@@ -10,12 +10,12 @@ from database import get_servers, get_tariff_by_id, store_key
|
||||
from database.models import User
|
||||
from handlers.utils import check_server_key_limit
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import (
|
||||
from panels._3xui import (
|
||||
ClientConfig,
|
||||
add_client,
|
||||
get_xui_instance,
|
||||
)
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
|
||||
async def create_key_on_cluster(
|
||||
|
||||
@@ -3,8 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from database import get_servers
|
||||
from logger import logger
|
||||
from panels._3xui import delete_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import delete_client, get_xui_instance
|
||||
|
||||
|
||||
async def delete_key_from_cluster(cluster_id: str, email: str, client_id: str, session: AsyncSession):
|
||||
|
||||
@@ -10,8 +10,8 @@ from database import delete_notification, get_servers
|
||||
from database.models import Key, Server, Tariff
|
||||
from database.notifications import clear_hot_lead_notifications
|
||||
from logger import logger
|
||||
from panels._3xui import ClientConfig, add_client, extend_client_key, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import ClientConfig, add_client, extend_client_key, get_xui_instance
|
||||
|
||||
|
||||
async def renew_key_in_cluster(
|
||||
@@ -134,11 +134,12 @@ async def renew_key_in_cluster(
|
||||
else:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
|
||||
tasks = []
|
||||
|
||||
for server_info in cluster:
|
||||
if server_info.get("panel_type", "3x-ui").lower() != "3x-ui":
|
||||
continue
|
||||
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
|
||||
@@ -155,21 +156,34 @@ async def renew_key_in_cluster(
|
||||
|
||||
traffic_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
|
||||
|
||||
async def update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name):
|
||||
updated = await extend_client_key(
|
||||
xui=xui,
|
||||
inbound_id=int(inbound_id),
|
||||
email=unique_email,
|
||||
new_expiry_time=new_expiry_time,
|
||||
client_id=client_id,
|
||||
total_gb=traffic_bytes,
|
||||
sub_id=sub_id,
|
||||
tg_id=tg_id,
|
||||
limit_ip=hwid_device_limit,
|
||||
)
|
||||
async def process_server(server_info, inbound_id, unique_email, sub_id, server_name):
|
||||
try:
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
except Exception as e:
|
||||
logger.warning(f"[{server_name}] недоступна панель 3x-ui: {e}")
|
||||
return server_name, False, f"api_unavailable: {e}"
|
||||
|
||||
if not updated:
|
||||
logger.warning(f"Не удалось обновить клиента {unique_email}, пробуем создать")
|
||||
try:
|
||||
updated = await extend_client_key(
|
||||
xui=xui,
|
||||
inbound_id=int(inbound_id),
|
||||
email=unique_email,
|
||||
new_expiry_time=new_expiry_time,
|
||||
client_id=client_id,
|
||||
total_gb=traffic_bytes,
|
||||
sub_id=sub_id,
|
||||
tg_id=tg_id,
|
||||
limit_ip=hwid_device_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{server_name}] ошибка при продлении: {e}")
|
||||
updated = False
|
||||
|
||||
if updated:
|
||||
return server_name, True, None
|
||||
|
||||
logger.warning(f"[{server_name}] не удалось обновить {unique_email}, пробуем создать")
|
||||
try:
|
||||
config = ClientConfig(
|
||||
client_id=client_id,
|
||||
email=unique_email,
|
||||
@@ -183,8 +197,29 @@ async def renew_key_in_cluster(
|
||||
sub_id=sub_id,
|
||||
)
|
||||
await add_client(xui, config)
|
||||
return server_name, True, None
|
||||
except Exception as e:
|
||||
logger.warning(f"[{server_name}] не удалось создать клиента: {e}")
|
||||
return server_name, False, f"create_failed: {e}"
|
||||
|
||||
tasks.append(update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name))
|
||||
tasks.append(process_server(server_info, inbound_id, unique_email, sub_id, server_name))
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
failed = []
|
||||
succeeded = []
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
failed.append(("unknown", f"task_exception: {r}"))
|
||||
continue
|
||||
name, ok, err = r
|
||||
if ok:
|
||||
succeeded.append(name)
|
||||
else:
|
||||
failed.append((name, err or "unknown_error"))
|
||||
if succeeded:
|
||||
logger.info(f"3x-ui продлено на: {', '.join(succeeded)}")
|
||||
if failed:
|
||||
logger.warning("3x-ui не продлено на: " + ", ".join([f"{n} ({e})" for n, e in failed]))
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import get_servers
|
||||
from logger import logger
|
||||
from panels._3xui import get_xui_instance, toggle_client
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import get_xui_instance, toggle_client
|
||||
|
||||
|
||||
async def toggle_client_on_cluster(
|
||||
|
||||
@@ -9,8 +9,8 @@ from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import get_servers
|
||||
from database.models import Key, Server
|
||||
from logger import logger
|
||||
from panels._3xui import get_client_traffic, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import get_client_traffic, get_xui_instance
|
||||
|
||||
|
||||
async def get_user_traffic(session: AsyncSession, tg_id: int, email: str) -> dict[str, Any]:
|
||||
|
||||
@@ -10,8 +10,8 @@ from database import get_servers, store_key
|
||||
from database.models import Key, Tariff
|
||||
from handlers.utils import get_least_loaded_cluster
|
||||
from logger import logger
|
||||
from panels._3xui import ClientConfig, add_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import ClientConfig, add_client, get_xui_instance
|
||||
|
||||
from .deletion import delete_key_from_cluster
|
||||
|
||||
|
||||
@@ -47,14 +47,13 @@ from handlers.texts import (
|
||||
get_renewal_message,
|
||||
)
|
||||
from handlers.utils import format_hours, format_minutes, get_russian_month
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
|
||||
from .hot_leads_notifications import notify_hot_leads
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
|
||||
from hooks.hooks import run_hooks
|
||||
|
||||
|
||||
router = Router()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
@@ -135,43 +134,36 @@ async def notify_24h_keys(
|
||||
threshold_time_24h: int,
|
||||
keys: list,
|
||||
):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 24 часа.")
|
||||
|
||||
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
tg_ids = [getattr(key, "tg_id", key["tg_id"]) for key in expiring_keys]
|
||||
emails = [key.email or "" for key in expiring_keys]
|
||||
allowed = await check_notifications_bulk(session, "key_24h", 24, tg_ids=tg_ids, emails=emails)
|
||||
|
||||
users = await check_notifications_bulk(session, "key_24h", 24, tg_ids=tg_ids, emails=emails)
|
||||
|
||||
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
tg_id = getattr(key, "tg_id", key["tg_id"])
|
||||
email = key.email or ""
|
||||
if (tg_id, email) not in allowed_set:
|
||||
continue
|
||||
|
||||
notification_id = f"{email}_key_24h"
|
||||
|
||||
can_notify = await check_notification_time(session, tg_id, notification_id, hours=24)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.expiry_time
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
|
||||
|
||||
can_notify = await check_notification_time(session, tg_id, notification_id, hours=24)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
notification_text = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
hours_left_formatted=hours_left_formatted,
|
||||
@@ -211,10 +203,10 @@ async def notify_24h_keys(
|
||||
if result:
|
||||
await add_notification(session, tg_id, msg["notification_id"])
|
||||
sent_count += 1
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
|
||||
logger.info(f"Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
|
||||
|
||||
@@ -230,35 +222,28 @@ async def notify_10h_keys(
|
||||
keys: list,
|
||||
):
|
||||
logger.info("Начало проверки подписок, истекающих через 10 часов.")
|
||||
|
||||
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
|
||||
|
||||
tg_ids = [key.tg_id for key in expiring_keys]
|
||||
emails = [key.email or "" for key in expiring_keys]
|
||||
allowed = await check_notifications_bulk(session, "key_10h", 10, tg_ids=tg_ids, emails=emails)
|
||||
|
||||
users = await check_notifications_bulk(session, "key_10h", 10, tg_ids=tg_ids, emails=emails)
|
||||
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
if (tg_id, email) not in allowed_set:
|
||||
continue
|
||||
|
||||
notification_id = f"{email}_key_10h"
|
||||
|
||||
can_notify = await check_notification_time(session, tg_id, notification_id, hours=10)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.expiry_time
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
|
||||
|
||||
@@ -268,6 +253,10 @@ async def notify_10h_keys(
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
can_notify = await check_notification_time(session, tg_id, notification_id, hours=10)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
@@ -301,10 +290,10 @@ async def notify_10h_keys(
|
||||
if result:
|
||||
await add_notification(session, tg_id, msg["notification_id"])
|
||||
sent_count += 1
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
|
||||
logger.info(f"Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
|
||||
|
||||
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
DONATIONS_ENABLE,
|
||||
FREEKASSA_ENABLE,
|
||||
HELEKET_ENABLE,
|
||||
KASSAI_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
WATA_INT_ENABLE,
|
||||
WATA_RU_ENABLE,
|
||||
WATA_SBP_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
TRIBUTE_ENABLE,
|
||||
TRIBUTE_LINK
|
||||
)
|
||||
from database import get_last_payments
|
||||
from database.models import User
|
||||
from handlers.buttons import (
|
||||
BALANCE_HISTORY,
|
||||
COUPON,
|
||||
CRYPTOBOT,
|
||||
FREEKASSA,
|
||||
HELEKET_CRYPTO,
|
||||
KASSAI_CARDS,
|
||||
KASSAI_SBP,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
ROBOKASSA,
|
||||
STARS,
|
||||
WATA_INT,
|
||||
WATA_RU,
|
||||
WATA_SBP,
|
||||
YOOKASSA,
|
||||
YOOMONEY,
|
||||
TRIBUTE,
|
||||
)
|
||||
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
|
||||
from handlers.payments.freekassa_pay import process_callback_pay_freekassa
|
||||
from handlers.payments.heleket import process_callback_pay_heleket
|
||||
from handlers.payments.kassai import process_callback_pay_kassai
|
||||
from handlers.payments.robokassa_pay import process_callback_pay_robokassa
|
||||
from handlers.payments.stars_pay import process_callback_pay_stars
|
||||
from handlers.payments.wata import process_callback_pay_wata
|
||||
from handlers.payments.yookassa_pay import process_callback_pay_yookassa
|
||||
from handlers.payments.yoomoney_pay import process_callback_pay_yoomoney
|
||||
from handlers.payments.tribute_pay import process_callback_pay_tribute
|
||||
from handlers.texts import BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay")
|
||||
async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
payment_handlers = []
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_yookassa)
|
||||
if YOOMONEY_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_yoomoney)
|
||||
if KASSAI_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_kassai)
|
||||
if HELEKET_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_heleket)
|
||||
if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_wata)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_cryptobot)
|
||||
if STARS_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_stars)
|
||||
if ROBOKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_robokassa)
|
||||
if FREEKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_freekassa)
|
||||
if TRIBUTE_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_tribute)
|
||||
|
||||
module_buttons = await run_hooks(
|
||||
"pay_menu_buttons",
|
||||
chat_id=callback_query.from_user.id,
|
||||
admin=False,
|
||||
session=session
|
||||
)
|
||||
has_extra_menu_items = bool(module_buttons) or bool(DONATIONS_ENABLE) or bool(TRIBUTE_ENABLE)
|
||||
|
||||
if len(payment_handlers) == 1 and not has_extra_menu_items:
|
||||
return await payment_handlers[0](callback_query, state, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=YOOKASSA, callback_data="pay_yookassa"))
|
||||
if YOOMONEY_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=YOOMONEY, callback_data="pay_yoomoney"))
|
||||
if KASSAI_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=KASSAI_CARDS, callback_data="pay_kassai_cards"))
|
||||
builder.row(InlineKeyboardButton(text=KASSAI_SBP, callback_data="pay_kassai_sbp"))
|
||||
if HELEKET_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=HELEKET_CRYPTO, callback_data="pay_heleket_crypto"))
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot"))
|
||||
if STARS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=STARS, callback_data="pay_stars"))
|
||||
if ROBOKASSA_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa"))
|
||||
if FREEKASSA_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=FREEKASSA, callback_data="pay_freekassa"))
|
||||
if WATA_RU_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=WATA_RU, callback_data="pay_wata_ru"))
|
||||
if WATA_SBP_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=WATA_SBP, callback_data="pay_wata_sbp"))
|
||||
if WATA_INT_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=WATA_INT, callback_data="pay_wata_int"))
|
||||
if TRIBUTE_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text=TRIBUTE, url=TRIBUTE_LINK))
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=PAYMENT_METHODS_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
|
||||
stmt = select(User.balance).where(User.tg_id == callback_query.from_user.id)
|
||||
result = await session.execute(stmt)
|
||||
balance = result.scalar_one_or_none() or 0.0
|
||||
balance = int(balance)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
|
||||
image_path = os.path.join("img", "pay.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance_history")
|
||||
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
records = await get_last_payments(session, callback_query.from_user.id)
|
||||
|
||||
if records:
|
||||
history_text = "<b>💳 История операций:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
history_text += f"Сумма: {amount}₽\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
|
||||
history_text += "</blockquote>"
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=history_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata_ru")
|
||||
async def handle_pay_wata_ru(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_wata(callback_query, state, session, cassa_name="ru")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata_sbp")
|
||||
async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_wata(callback_query, state, session, cassa_name="sbp")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata_int")
|
||||
async def handle_pay_wata_int(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_wata(callback_query, state, session, cassa_name="int")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_cards")
|
||||
async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="cards")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_sbp")
|
||||
async def handle_pay_kassai_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="sbp")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_heleket_crypto")
|
||||
async def handle_pay_heleket_crypto(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_heleket(callback_query, state, session, method_name="crypto")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_tribute")
|
||||
async def handle_pay_tribute(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_tribute(callback_query, state, session)
|
||||
@@ -2,50 +2,48 @@ __all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
FREEKASSA_ENABLE,
|
||||
KASSAI_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
HELEKET_ENABLE,
|
||||
TRIBUTE_ENABLE
|
||||
)
|
||||
from config import PROVIDERS_ENABLED
|
||||
from handlers.payments.providers import get_providers
|
||||
|
||||
from .cryprobot_pay import router as cryprobot_router
|
||||
from .freekassa_pay import router as freekassa_router
|
||||
from .cryptobot import router as cryptobot_router
|
||||
from .fast_payment_flow import router as fast_payment_flow_router
|
||||
from .freekassa.freekassa_pay import router as freekassa_router
|
||||
from .gift import router as gift_router
|
||||
from .kassai import router as kassai_router
|
||||
from .robokassa_pay import router as robokassa_router
|
||||
from .stars_pay import router as stars_router
|
||||
from .yookassa_pay import router as yookassa_router
|
||||
from .yoomoney_pay import router as yoomoney_router
|
||||
from .wata import router as wata_router
|
||||
from .heleket import router as heleket_router
|
||||
from .tribute_pay import router as tribute_router
|
||||
from .heleket.heleket import router as heleket_router
|
||||
from .kassai.kassai import router as kassai_router
|
||||
from .pay import router as pay_router
|
||||
from .robokassa import router as robokassa_router
|
||||
from .stars import router as stars_router
|
||||
from .tribute import router as tribute_router
|
||||
from .wata.wata import router as wata_router
|
||||
from .yookassa import router as yookassa_router
|
||||
from .yoomoney import router as yoomoney_router
|
||||
|
||||
|
||||
router = Router(name="payments_main_router")
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
PROVIDERS = get_providers(PROVIDERS_ENABLED)
|
||||
|
||||
if PROVIDERS.get("YOOKASSA", {}).get("enabled"):
|
||||
router.include_router(yookassa_router)
|
||||
if YOOMONEY_ENABLE:
|
||||
if PROVIDERS.get("YOOMONEY", {}).get("enabled"):
|
||||
router.include_router(yoomoney_router)
|
||||
if ROBOKASSA_ENABLE:
|
||||
if PROVIDERS.get("ROBOKASSA", {}).get("enabled"):
|
||||
router.include_router(robokassa_router)
|
||||
if FREEKASSA_ENABLE:
|
||||
if PROVIDERS.get("FREEKASSA", {}).get("enabled"):
|
||||
router.include_router(freekassa_router)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
router.include_router(cryprobot_router)
|
||||
if STARS_ENABLE:
|
||||
if PROVIDERS.get("CRYPTOBOT", {}).get("enabled"):
|
||||
router.include_router(cryptobot_router)
|
||||
if PROVIDERS.get("STARS", {}).get("enabled"):
|
||||
router.include_router(stars_router)
|
||||
if KASSAI_ENABLE:
|
||||
if PROVIDERS.get("KASSAI_CARDS", {}).get("enabled") or PROVIDERS.get("KASSAI_SBP", {}).get("enabled"):
|
||||
router.include_router(kassai_router)
|
||||
if HELEKET_ENABLE:
|
||||
if PROVIDERS.get("HELEKET", {}).get("enabled"):
|
||||
router.include_router(heleket_router)
|
||||
if TRIBUTE_ENABLE:
|
||||
if PROVIDERS.get("TRIBUTE", {}).get("enabled"):
|
||||
router.include_router(tribute_router)
|
||||
|
||||
router.include_router(wata_router)
|
||||
router.include_router(gift_router)
|
||||
router.include_router(pay_router)
|
||||
router.include_router(fast_payment_flow_router)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
from typing import Iterable, List, Any
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.texts import FAST_PAY_NOT_ENOUGH
|
||||
from handlers.buttons import RUB_CURRENCY, USD_CURRENCY, STARS, MAIN_MENU
|
||||
from .currency_rates import format_for_user
|
||||
|
||||
|
||||
def build_currency_choice_kb(show_stars: bool, *, prefix: str = "choose_payment_currency") -> InlineKeyboardBuilder:
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.row(InlineKeyboardButton(text=RUB_CURRENCY, callback_data=f"{prefix}|RUB"))
|
||||
kb.row(InlineKeyboardButton(text=USD_CURRENCY, callback_data=f"{prefix}|USD"))
|
||||
if show_stars:
|
||||
kb.row(InlineKeyboardButton(text=STARS, callback_data=f"{prefix}|STARS"))
|
||||
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
return kb
|
||||
|
||||
|
||||
async def shortfall_lead_text(
|
||||
session: Any,
|
||||
tg_id: int,
|
||||
required_amount: int | float | None,
|
||||
language_code: str | None,
|
||||
*,
|
||||
force_currency: str | None = None,
|
||||
) -> str:
|
||||
if not isinstance(required_amount, (int, float)) or required_amount <= 0:
|
||||
return "💳"
|
||||
amount_txt = await format_for_user(
|
||||
session, tg_id, float(required_amount), language_code, force_currency=force_currency
|
||||
)
|
||||
return FAST_PAY_NOT_ENOUGH.format(amount=amount_txt)
|
||||
|
||||
|
||||
def filter_providers_by_currency(
|
||||
currency: str,
|
||||
providers: Iterable[str],
|
||||
rub_providers: Iterable[str],
|
||||
) -> List[str]:
|
||||
rub_set = {p.upper() for p in rub_providers}
|
||||
out: List[str] = []
|
||||
for p in providers:
|
||||
up = p.upper()
|
||||
if currency == "RUB":
|
||||
if up in rub_set or up == "WATA":
|
||||
out.append(p)
|
||||
elif currency == "USD":
|
||||
if (up not in rub_set or up == "WATA") and up != "STARS":
|
||||
out.append(p)
|
||||
elif currency == "STARS":
|
||||
if up == "STARS":
|
||||
out.append(p)
|
||||
else:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def currency_for_provider(up_provider: str, rub_providers: Iterable[str]) -> str | None:
|
||||
if up_provider in {p.upper() for p in rub_providers}:
|
||||
return "RUB"
|
||||
if up_provider == "STARS":
|
||||
return "STARS"
|
||||
if up_provider == "WATA":
|
||||
return None
|
||||
return "USD"
|
||||
|
||||
|
||||
def currency_label(code: str) -> str:
|
||||
if code == "RUB":
|
||||
return "RUB"
|
||||
if code == "USD":
|
||||
return "USD/Cryptowallet"
|
||||
if code == "STARS":
|
||||
return "Telegram Stars"
|
||||
return code
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
import sqlalchemy as sa
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
import aiohttp
|
||||
from config import MULTICURRENCY_ENABLE, FX_MARKUP
|
||||
|
||||
|
||||
CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js"
|
||||
CACHE_TTL = 60 * 30
|
||||
|
||||
cache: dict[str, tuple[float, Decimal]] = {}
|
||||
|
||||
|
||||
def _q(x: Decimal, prec: int = 8) -> Decimal:
|
||||
return x.quantize(Decimal(10) ** -prec, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _round2(x: Decimal) -> Decimal:
|
||||
return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
async def to_rub(amount: float | Decimal, base: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
|
||||
"""
|
||||
Переводит сумму ИЗ валюты base В РУБЛИ.
|
||||
Использует get_rub_rate(base): base_per_rub, т.е. СКОЛЬКО единиц base в 1 рубле.
|
||||
RUB = amount / (base_per_rub).
|
||||
"""
|
||||
rate = await get_rub_rate(base, session=session)
|
||||
return _q(Decimal(amount) / rate, prec=2)
|
||||
|
||||
|
||||
async def get_rub_rate(quote: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
|
||||
"""
|
||||
Возвращает курс 'QUOTE per RUB' (сколько единиц валюты QUOTE приходится на 1 рубль).
|
||||
Пример: для USD при 100 RUB за 1 USD вернёт 0.01 USD/RUB.
|
||||
Здесь же применяется наценка FX_MARKUP (если задана и валюта не RUB).
|
||||
"""
|
||||
code = quote.upper()
|
||||
if code == "RUB":
|
||||
return Decimal("1")
|
||||
|
||||
now = time.time()
|
||||
cached = cache.get(code)
|
||||
if cached and now - cached[0] < CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
owns = False
|
||||
s = session
|
||||
if s is None:
|
||||
s = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||
owns = True
|
||||
try:
|
||||
async with s.get(CBR_URL, headers={"Accept": "application/json"}) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json(content_type=None)
|
||||
finally:
|
||||
if owns and not s.closed:
|
||||
await s.close()
|
||||
|
||||
valutes = data.get("Valute") or {}
|
||||
v = valutes.get(code)
|
||||
if not v:
|
||||
raise ValueError(f"Валюта {code} не найдена у ЦБ")
|
||||
|
||||
rub_per_unit = Decimal(str(v["Value"])) / Decimal(str(v.get("Nominal", 1)))
|
||||
rate = _q(Decimal("1") / rub_per_unit)
|
||||
|
||||
if code != "RUB" and FX_MARKUP:
|
||||
rate = _q(rate * (Decimal("1") + Decimal(str(FX_MARKUP))))
|
||||
|
||||
cache[code] = (now, rate)
|
||||
return rate
|
||||
|
||||
|
||||
async def convert_from_rub(
|
||||
amount_rub: Decimal | float,
|
||||
to_ccy: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession | None = None
|
||||
) -> Decimal:
|
||||
"""
|
||||
Конвертирует сумму из RUB в валюту to_ccy, используя get_rub_rate(to_ccy).
|
||||
"""
|
||||
amt = Decimal(str(amount_rub))
|
||||
ccy = to_ccy.upper()
|
||||
if ccy == "RUB":
|
||||
return _round2(amt)
|
||||
rate = await get_rub_rate(ccy, session=session)
|
||||
val = amt * rate
|
||||
return _round2(val)
|
||||
|
||||
|
||||
def pick_currency(
|
||||
language_code: str | None,
|
||||
user_currency: str | None = None,
|
||||
force_currency: str | None = None
|
||||
) -> str:
|
||||
if not MULTICURRENCY_ENABLE:
|
||||
return "RUB"
|
||||
if force_currency in {"USD", "RUB"}:
|
||||
return force_currency
|
||||
if user_currency in {"USD", "RUB"}:
|
||||
return user_currency
|
||||
code = (language_code or "").split("-")[0].lower()
|
||||
return "RUB" if code == "ru" else "USD"
|
||||
|
||||
|
||||
def fmt_money(amount: Decimal, currency: str, language_code: str | None) -> str:
|
||||
q = _round2(amount)
|
||||
if currency == "USD":
|
||||
s = f"{q:,.2f}"
|
||||
if (language_code or "").startswith("ru"):
|
||||
s = s.replace(",", " ")
|
||||
return f"${s}"
|
||||
s = f"{q:,.2f}".replace(",", " ")
|
||||
return f"{s} ₽"
|
||||
|
||||
|
||||
async def display_price(
|
||||
amount_rub: Decimal | float,
|
||||
language_code: str | None,
|
||||
*,
|
||||
user_currency: str | None = None,
|
||||
force_currency: str | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> tuple[str, str, Decimal]:
|
||||
if force_currency in {"USD", "RUB"}:
|
||||
cur = force_currency
|
||||
if cur == "RUB":
|
||||
val = _round2(Decimal(str(amount_rub)))
|
||||
else:
|
||||
val = await convert_from_rub(Decimal(str(amount_rub)), "USD", session=session)
|
||||
txt = fmt_money(val, cur, language_code)
|
||||
return txt, cur, val
|
||||
|
||||
val = _round2(Decimal(str(amount_rub)))
|
||||
txt = fmt_money(val, "RUB", language_code)
|
||||
return txt, "RUB", val
|
||||
|
||||
|
||||
async def money_for_user(
|
||||
db_session,
|
||||
tg_id: int,
|
||||
amount_rub: float | int | Decimal,
|
||||
language_code: Optional[str],
|
||||
force_currency: Optional[str] = None,
|
||||
) -> Tuple[str, str, Decimal]:
|
||||
"""
|
||||
Возвращает: (text, currency, value)
|
||||
- text: строка для показа пользователю, например "$12.34" или "1 234.00 ₽"
|
||||
- currency: "USD" или "RUB"
|
||||
- value: Decimal в выбранной валюте
|
||||
"""
|
||||
row = await db_session.execute(
|
||||
sa.text("select preferred_currency from users where tg_id = :id"),
|
||||
{"id": tg_id},
|
||||
)
|
||||
user_currency = row.scalar()
|
||||
|
||||
txt, cur, val = await display_price(
|
||||
amount_rub,
|
||||
language_code,
|
||||
user_currency=user_currency,
|
||||
force_currency=force_currency,
|
||||
session=None,
|
||||
)
|
||||
return txt, cur, val
|
||||
|
||||
|
||||
async def format_for_user(
|
||||
db_session,
|
||||
tg_id: int,
|
||||
amount_rub: float | int | Decimal,
|
||||
language_code: Optional[str],
|
||||
force_currency: Optional[str] = None,
|
||||
) -> str:
|
||||
text, _, _ = await money_for_user(
|
||||
db_session,
|
||||
tg_id,
|
||||
amount_rub,
|
||||
language_code,
|
||||
force_currency=force_currency,
|
||||
)
|
||||
return text
|
||||
@@ -0,0 +1,217 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW, MULTICURRENCY_ENABLE, PROVIDERS_ENABLED
|
||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER
|
||||
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers import buttons as btn
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from handlers.payments.currency_flow import (
|
||||
build_currency_choice_kb,
|
||||
shortfall_lead_text,
|
||||
currency_label,
|
||||
)
|
||||
from handlers.payments.providers import get_providers_with_hooks
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def _run_provider_flow(
|
||||
provider: str,
|
||||
callback_query: CallbackQuery,
|
||||
session: Any,
|
||||
state: FSMContext,
|
||||
required_amount: int | None,
|
||||
) -> bool:
|
||||
import importlib
|
||||
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
up = provider.upper()
|
||||
cfg = (PROVIDERS.get(up) or {})
|
||||
fast_name = cfg.get("fast")
|
||||
if not fast_name:
|
||||
return False
|
||||
|
||||
module_name = f"handlers.payments.{up.lower()}.handlers"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
func = getattr(module, fast_name)
|
||||
except Exception as e:
|
||||
logger.error(f"[FAST_FLOW] Импорт {up}.{fast_name} из {module_name} не удался: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
if up == "STARS":
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"[FAST_FLOW] Не удалось удалить меню перед STARS: {e}")
|
||||
await func(callback_query, session)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[FAST_FLOW] Ошибка при вызове {up}.{fast_name}(): {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def try_fast_payment_flow(
|
||||
callback_query: CallbackQuery,
|
||||
session: Any,
|
||||
state: FSMContext,
|
||||
*,
|
||||
tg_id: int,
|
||||
temp_key: str,
|
||||
temp_payload: dict,
|
||||
required_amount: int | None = None,
|
||||
) -> bool:
|
||||
await create_temporary_data(session, tg_id, temp_key, temp_payload)
|
||||
|
||||
if not USE_NEW_PAYMENT_FLOW:
|
||||
return False
|
||||
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
|
||||
providers = (
|
||||
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
)
|
||||
providers = [
|
||||
p for p in providers
|
||||
if (PROVIDERS.get(str(p).upper()) or {}).get("fast")
|
||||
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
|
||||
]
|
||||
if not providers:
|
||||
return False
|
||||
|
||||
if len(providers) == 1:
|
||||
up = providers[0].upper()
|
||||
cfg = PROVIDERS.get(up) or {}
|
||||
currency = cfg.get("currency")
|
||||
if currency:
|
||||
await state.update_data(chosen_currency=currency)
|
||||
if await _run_provider_flow(up, callback_query, session, state, required_amount):
|
||||
return True
|
||||
return False
|
||||
|
||||
if MULTICURRENCY_ENABLE:
|
||||
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
|
||||
kb = build_currency_choice_kb(show_stars=show_stars)
|
||||
lead = await shortfall_lead_text(
|
||||
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
|
||||
)
|
||||
text = f"{lead}.\n\n{FAST_PAY_CHOOSE_CURRENCY}"
|
||||
await state.update_data(
|
||||
temp_key=temp_key,
|
||||
temp_payload=temp_payload,
|
||||
required_amount=required_amount,
|
||||
fastflow_providers=providers,
|
||||
)
|
||||
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=kb.as_markup())
|
||||
return True
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for p in providers:
|
||||
up = p.upper()
|
||||
btn_text = getattr(btn, up, up)
|
||||
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{up}"))
|
||||
|
||||
kb.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
lead = await shortfall_lead_text(
|
||||
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
|
||||
)
|
||||
await state.update_data(temp_key=temp_key, temp_payload=temp_payload, required_amount=required_amount)
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=f"{lead}.\n\n{FAST_PAY_CHOOSE_PROVIDER}",
|
||||
reply_markup=kb.as_markup(),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("choose_payment_currency|"))
|
||||
async def choose_payment_currency(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
currency = callback_query.data.split("|")[1]
|
||||
data = await state.get_data()
|
||||
|
||||
providers = data.get("fastflow_providers") or (
|
||||
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
|
||||
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
)
|
||||
filtered = [
|
||||
p.upper() for p in providers
|
||||
if (PROVIDERS.get(str(p).upper()) or {}).get("currency") == currency
|
||||
and (PROVIDERS.get(str(p).upper()) or {}).get("fast")
|
||||
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
|
||||
]
|
||||
await state.update_data(chosen_currency=currency)
|
||||
|
||||
if not filtered:
|
||||
kb = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Для выбранной валюты нет доступных касс. Выберите другую валюту или вернитесь в меню.",
|
||||
reply_markup=kb.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if len(filtered) == 1:
|
||||
only = filtered[0]
|
||||
if await _run_provider_flow(only, callback_query, session, state, data.get("required_amount")):
|
||||
return
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Этот способ временно недоступен.",
|
||||
reply_markup=InlineKeyboardBuilder().row(
|
||||
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
|
||||
).as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for p in filtered:
|
||||
btn_text = getattr(btn, p, p)
|
||||
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{p}"))
|
||||
|
||||
kb.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
lead = await shortfall_lead_text(
|
||||
session,
|
||||
callback_query.from_user.id,
|
||||
data.get("required_amount"),
|
||||
getattr(callback_query.from_user, "language_code", None),
|
||||
force_currency=currency,
|
||||
)
|
||||
text = f"{lead}.\n\nВалюта: {currency_label(currency)}\n{FAST_PAY_CHOOSE_PROVIDER}"
|
||||
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("choose_payment_provider|"))
|
||||
async def choose_payment_provider(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
provider = callback_query.data.split("|")[1].upper()
|
||||
cfg = PROVIDERS.get(provider) or {}
|
||||
if not cfg.get("fast") or not cfg.get("enabled", True):
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Этот способ временно недоступен.",
|
||||
reply_markup=InlineKeyboardBuilder().row(
|
||||
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
|
||||
).as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
currency = cfg.get("currency")
|
||||
if currency:
|
||||
await state.update_data(chosen_currency=currency)
|
||||
|
||||
data = await state.get_data()
|
||||
await _run_provider_flow(provider, callback_query, session, state, data.get("required_amount"))
|
||||
+365
-405
@@ -1,405 +1,365 @@
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
from robokassa import HashAlgorithm, Robokassa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, and_
|
||||
from pytz import timezone
|
||||
from urllib.parse import urlencode, quote_plus
|
||||
from config import (
|
||||
ROBOKASSA_ENABLE,
|
||||
ROBOKASSA_LOGIN,
|
||||
ROBOKASSA_PASSWORD1,
|
||||
ROBOKASSA_PASSWORD2,
|
||||
ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
from database import (
|
||||
add_payment,
|
||||
add_user,
|
||||
async_session_maker,
|
||||
check_user_exists,
|
||||
get_key_count,
|
||||
get_temporary_data,
|
||||
update_balance,
|
||||
Payment
|
||||
)
|
||||
import json
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from handlers.buttons import BACK, PAY_2
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
MOSCOW_TZ = timezone("Europe/Moscow")
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_robokassa = State()
|
||||
waiting_for_payment_confirmation_robokassa = State()
|
||||
|
||||
|
||||
if ROBOKASSA_ENABLE:
|
||||
robokassa = Robokassa(
|
||||
merchant_login=ROBOKASSA_LOGIN,
|
||||
password1=ROBOKASSA_PASSWORD1,
|
||||
password2=ROBOKASSA_PASSWORD2,
|
||||
algorithm=HashAlgorithm.md5,
|
||||
is_test=ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
|
||||
logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN)
|
||||
|
||||
|
||||
def _build_receipt(amount: float, *, sno: str = "usn_income") -> dict:
|
||||
receipt = {
|
||||
"items": [{
|
||||
"name": "Пополнение баланса",
|
||||
"quantity": 1,
|
||||
"sum": float(amount),
|
||||
"payment_method": "full_payment",
|
||||
"payment_object": "payment",
|
||||
"tax": "none",
|
||||
}],
|
||||
"sno": sno,
|
||||
}
|
||||
return receipt
|
||||
|
||||
|
||||
def _format_amount(amount: float | int) -> str:
|
||||
s = str(Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_DOWN))
|
||||
if "." in s:
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return s
|
||||
|
||||
def generate_payment_link(amount, inv_id, description, tg_id):
|
||||
out_sum = _format_amount(amount)
|
||||
|
||||
receipt = _build_receipt(amount)
|
||||
receipt_json = json.dumps(receipt, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
receipt_enc1 = quote_plus(receipt_json, safe="")
|
||||
|
||||
shp = {"Shp_id": str(tg_id)}
|
||||
|
||||
base = f"{ROBOKASSA_LOGIN}:{out_sum}:{inv_id}:{receipt_enc1}:{ROBOKASSA_PASSWORD1}"
|
||||
for k in sorted(shp.keys(), key=str.lower):
|
||||
base += f":{k}={shp[k]}"
|
||||
|
||||
signature = hashlib.md5(base.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
query = {
|
||||
"MrchLogin": ROBOKASSA_LOGIN,
|
||||
"OutSum": out_sum,
|
||||
"InvId": inv_id,
|
||||
"Description": description,
|
||||
"Receipt": receipt_enc1,
|
||||
"SignatureValue": signature,
|
||||
**shp,
|
||||
}
|
||||
if ROBOKASSA_TEST_MODE:
|
||||
query["IsTest"] = 1
|
||||
|
||||
return "https://auth.robokassa.ru/Merchant/Index.aspx?" + urlencode(query)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_robokassa")
|
||||
async def process_callback_pay_robokassa(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated Robokassa payment.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
key_count = await get_key_count(session, tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_user_exists(session, tg_id)
|
||||
if not exists:
|
||||
from_user = callback_query.from_user
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
|
||||
|
||||
await callback_query.message.delete()
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.update_data(
|
||||
message_id=new_message.message_id, chat_id=new_message.chat.id
|
||||
)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
|
||||
logger.info(f"Displayed amount selection for user {tg_id}.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("robokassa_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
logger.info(f"Получены данные callback_data: {callback_query.data}")
|
||||
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) != 3 or data[1] != "amount":
|
||||
logger.error("Ошибка: callback_data не соответствует формату.")
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: данные повреждены.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
amount_str = data[2]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма должна быть положительным числом.")
|
||||
except ValueError as e:
|
||||
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Некорректная сумма.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.")
|
||||
inv_id = 0
|
||||
|
||||
tg_id = callback_query.message.chat.id
|
||||
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
|
||||
|
||||
logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}")
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="pay_robokassa")],
|
||||
]
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
|
||||
|
||||
|
||||
async def robokassa_webhook(request: web.Request):
|
||||
try:
|
||||
params = await request.post()
|
||||
|
||||
logger.info(f"Received webhook params: {params}")
|
||||
|
||||
amount = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
shp_id = params.get("Shp_id") or params.get("shp_id") or params.get("id")
|
||||
signature_value = params.get("SignatureValue")
|
||||
|
||||
logger.info(
|
||||
f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}"
|
||||
)
|
||||
|
||||
if not check_payment_signature(params):
|
||||
logger.error("Неверная подпись или данные запроса.")
|
||||
return web.Response(status=400)
|
||||
|
||||
if not amount or not inv_id or not shp_id:
|
||||
logger.error("Отсутствуют обязательные параметры.")
|
||||
return web.Response(status=400)
|
||||
|
||||
tg_id = shp_id
|
||||
logger.info(f"Processing payment for user {tg_id} with amount {amount}.")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
recent_time = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(seconds=10)
|
||||
|
||||
result = await session.execute(
|
||||
select(Payment).where(
|
||||
and_(
|
||||
Payment.tg_id == int(tg_id),
|
||||
Payment.amount == float(amount),
|
||||
Payment.status == "success",
|
||||
Payment.created_at >= recent_time
|
||||
)
|
||||
)
|
||||
)
|
||||
duplicate = result.scalar_one_or_none()
|
||||
|
||||
if duplicate:
|
||||
logger.warning(f"[Robokassa] Повторный webhook. Платёж уже обработан: tg_id={tg_id}, amount={amount}")
|
||||
return web.Response(text=f"OK{inv_id}")
|
||||
|
||||
await update_balance(session, int(tg_id), float(amount))
|
||||
await send_payment_success_notification(tg_id, float(amount), session)
|
||||
await add_payment(session, int(tg_id), float(amount), "robokassa")
|
||||
|
||||
logger.info(f"✅ Payment successful. Balance updated for user {tg_id}.")
|
||||
return web.Response(text=f"OK{inv_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
def check_payment_signature(params):
|
||||
"""Проверка подписи ResultURL от Robokassa с учётом всех Shp_*."""
|
||||
out_sum = params.get("OutSum") or params.get("out_summ") or params.get("outsumm")
|
||||
inv_id = params.get("InvId") or params.get("inv_id") or params.get("invid")
|
||||
received_sig = (params.get("SignatureValue") or params.get("signaturevalue") or "").upper()
|
||||
|
||||
if not out_sum or not inv_id or not received_sig:
|
||||
logger.error("Missing required params for signature check.")
|
||||
return False
|
||||
|
||||
shp_items = [(k, params[k]) for k in params.keys() if k.lower().startswith("shp_")]
|
||||
shp_items.sort(key=lambda kv: kv[0].lower())
|
||||
shp_suffix = "".join(f":{k}={v}" for k, v in shp_items)
|
||||
base = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}{shp_suffix}"
|
||||
expected_sig = hashlib.md5(base.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
logger.info(f"Signature base (RESULT): {base}")
|
||||
logger.info(f"Expected signature: {expected_sig}")
|
||||
logger.info(f"Received signature: {received_sig}")
|
||||
|
||||
return received_sig == expected_sig
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_robokassa")
|
||||
async def process_custom_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} chose to enter a custom amount.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=ENTER_SUM,
|
||||
reply_markup=builder.as_markup(),
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_robokassa
|
||||
)
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
async def handle_custom_amount_input(
|
||||
message: types.Message | types.CallbackQuery,
|
||||
state: FSMContext = None,
|
||||
session: AsyncSession = None,
|
||||
):
|
||||
if isinstance(message, types.CallbackQuery):
|
||||
tg_id = message.message.chat.id
|
||||
target_message = message.message
|
||||
else:
|
||||
tg_id = message.chat.id
|
||||
target_message = message
|
||||
|
||||
logger.info(f"User {tg_id} initiated payment through ROBOKASSA")
|
||||
inv_id = 0
|
||||
|
||||
try:
|
||||
user_data = await get_temporary_data(session, tg_id)
|
||||
|
||||
if not user_data:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Данные для оплаты не найдены. Попробуйте снова.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
return
|
||||
|
||||
state_type = user_data["state"]
|
||||
amount = user_data["data"].get("required_amount", 0)
|
||||
|
||||
if amount <= 0:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Недостаточная сумма для пополнения.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
|
||||
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
if state_type == "waiting_for_payment":
|
||||
message_text = f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
|
||||
elif state_type == "waiting_for_renewal_payment":
|
||||
message_text = f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
|
||||
else:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Некорректное состояние данных. Попробуйте снова.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
if isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
import hashlib
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
FREEKASSA_SECRET1,
|
||||
FREEKASSA_SECRET2,
|
||||
FREEKASSA_SHOP_ID,
|
||||
)
|
||||
from database import (
|
||||
add_payment,
|
||||
add_user,
|
||||
async_session_maker,
|
||||
check_user_exists,
|
||||
get_key_count,
|
||||
get_temporary_data,
|
||||
update_balance,
|
||||
)
|
||||
from database.models import Payment
|
||||
from handlers.buttons import BACK, PAY_2
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_freekassa = State()
|
||||
waiting_for_payment_confirmation_freekassa = State()
|
||||
|
||||
|
||||
def generate_signature(shop_id: int, amount: float, secret: str, order_id: str, currency: str = "RUB") -> str:
|
||||
signature_string = f"{shop_id}:{amount}:{secret}:{currency}:{order_id}"
|
||||
signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest()
|
||||
logger.debug(f"Generated signature for order {order_id}: {signature}")
|
||||
return signature
|
||||
|
||||
|
||||
def generate_payment_link(amount: float, order_id: str, tg_id: int, currency: str = "RUB") -> str:
|
||||
signature = generate_signature(FREEKASSA_SHOP_ID, amount, FREEKASSA_SECRET1, order_id, currency)
|
||||
|
||||
payment_url = "https://pay.fk.money/"
|
||||
params = {
|
||||
"m": FREEKASSA_SHOP_ID,
|
||||
"oa": amount,
|
||||
"currency": currency,
|
||||
"o": order_id,
|
||||
"s": signature,
|
||||
"us_tg_id": tg_id,
|
||||
}
|
||||
|
||||
query_string = "&".join([f"{key}={value}" for key, value in params.items()])
|
||||
full_url = f"{payment_url}?{query_string}"
|
||||
|
||||
logger.info(f"Generated Freekassa payment link: {full_url}")
|
||||
return full_url
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_freekassa")
|
||||
async def process_callback_pay_freekassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated Freekassa payment.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
key_count = await get_key_count(session, tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_user_exists(session, tg_id)
|
||||
if not exists:
|
||||
from_user = callback_query.from_user
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[DB] Новый пользователь {tg_id} создан через Freekassa.")
|
||||
|
||||
await callback_query.message.delete()
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_freekassa)
|
||||
logger.info(f"Displayed amount selection for user {tg_id}.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freekassa_amount|"))
|
||||
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
logger.info(f"Получены данные callback_data: {callback_query.data}")
|
||||
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) != 3 or data[1] != "amount":
|
||||
logger.error("Ошибка: callback_data не соответствует формату.")
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: данные повреждены.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
amount_str = data[2]
|
||||
try:
|
||||
amount = float(amount_str)
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма должна быть положительным числом.")
|
||||
except ValueError as e:
|
||||
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Некорректная сумма.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.")
|
||||
|
||||
tg_id = callback_query.message.chat.id
|
||||
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
|
||||
|
||||
payment_url = generate_payment_link(amount, order_id, tg_id)
|
||||
|
||||
logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}")
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")],
|
||||
]
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
|
||||
|
||||
|
||||
def verify_signature(params: dict) -> bool:
|
||||
try:
|
||||
merchant_id = params.get("MERCHANT_ID", "")
|
||||
amount = params.get("AMOUNT", "")
|
||||
merchant_order_id = params.get("MERCHANT_ORDER_ID", "")
|
||||
sign = params.get("SIGN", "")
|
||||
|
||||
signature_string = f"{merchant_id}:{amount}:{FREEKASSA_SECRET2}:{merchant_order_id}"
|
||||
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest()
|
||||
|
||||
logger.debug(f"Signature verification: expected={expected_signature}, received={sign}")
|
||||
|
||||
return expected_signature == sign
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying signature: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def freekassa_webhook(request: web.Request):
|
||||
try:
|
||||
params = dict(request.query)
|
||||
logger.info(f"Received Freekassa webhook: {params}")
|
||||
|
||||
merchant_id = params.get("MERCHANT_ID")
|
||||
amount = params.get("AMOUNT")
|
||||
merchant_order_id = params.get("MERCHANT_ORDER_ID")
|
||||
sign = params.get("SIGN")
|
||||
tg_id = params.get("us_tg_id")
|
||||
|
||||
if not all([merchant_id, amount, merchant_order_id, sign]):
|
||||
logger.error("Missing required parameters in webhook")
|
||||
return web.Response(status=400, text="Missing required parameters")
|
||||
|
||||
if not verify_signature(params):
|
||||
logger.error("Invalid signature in webhook")
|
||||
return web.Response(status=400, text="Invalid signature")
|
||||
|
||||
if str(merchant_id) != str(FREEKASSA_SHOP_ID):
|
||||
logger.error(f"Invalid merchant_id: {merchant_id}")
|
||||
return web.Response(status=400, text="Invalid merchant_id")
|
||||
|
||||
try:
|
||||
amount_float = float(amount)
|
||||
if tg_id:
|
||||
tg_id_int = int(tg_id)
|
||||
else:
|
||||
order_parts = merchant_order_id.split("_")
|
||||
if len(order_parts) >= 3 and order_parts[0] == "order":
|
||||
tg_id_int = int(order_parts[1])
|
||||
else:
|
||||
logger.error(f"Cannot extract tg_id from order_id: {merchant_order_id}")
|
||||
return web.Response(status=400, text="Cannot identify user")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Error parsing parameters: {e}")
|
||||
return web.Response(status=400, text="Invalid parameter format")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
recent_time = datetime.utcnow() - timedelta(minutes=1)
|
||||
result = await session.execute(
|
||||
select(Payment).where(
|
||||
and_(
|
||||
Payment.tg_id == tg_id_int,
|
||||
Payment.amount == amount_float,
|
||||
Payment.status == "success",
|
||||
Payment.created_at >= recent_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
duplicate = result.scalar_one_or_none()
|
||||
|
||||
if duplicate:
|
||||
logger.warning(
|
||||
f"[Freekassa] Повторный webhook. Платёж уже обработан: tg_id={tg_id_int}, amount={amount_float}"
|
||||
)
|
||||
return web.Response(text="YES")
|
||||
|
||||
await update_balance(session, tg_id_int, amount_float)
|
||||
await send_payment_success_notification(tg_id_int, amount_float, session)
|
||||
await add_payment(session, tg_id_int, amount_float, "freekassa")
|
||||
|
||||
logger.info(f"Payment processed successfully. User: {tg_id_int}, Amount: {amount_float}")
|
||||
return web.Response(text="YES")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Freekassa webhook: {e}")
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_freekassa")
|
||||
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} chose to enter a custom amount.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=ENTER_SUM,
|
||||
reply_markup=builder.as_markup(),
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa)
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa)
|
||||
async def handle_custom_amount_input(
|
||||
message: types.Message | types.CallbackQuery,
|
||||
state: FSMContext = None,
|
||||
session: AsyncSession = None,
|
||||
):
|
||||
if isinstance(message, types.CallbackQuery):
|
||||
tg_id = message.message.chat.id
|
||||
target_message = message.message
|
||||
else:
|
||||
tg_id = message.chat.id
|
||||
target_message = message
|
||||
|
||||
logger.info(f"User {tg_id} initiated payment through Freekassa")
|
||||
|
||||
try:
|
||||
user_data = await get_temporary_data(session, tg_id)
|
||||
|
||||
if not user_data:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Данные для оплаты не найдены. Попробуйте снова.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
return
|
||||
|
||||
state_type = user_data["state"]
|
||||
amount = user_data["data"].get("required_amount", 0)
|
||||
|
||||
if amount <= 0:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Недостаточная сумма для пополнения.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
return
|
||||
|
||||
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
|
||||
payment_url = generate_payment_link(amount, order_id, tg_id)
|
||||
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa"))
|
||||
|
||||
if state_type == "waiting_for_payment":
|
||||
message_text = (
|
||||
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
elif state_type == "waiting_for_renewal_payment":
|
||||
message_text = (
|
||||
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
else:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Некорректное состояние данных. Попробуйте снова.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
if isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,10 @@
|
||||
import aiohttp
|
||||
import hashlib
|
||||
import base64
|
||||
import time
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
import aiohttp
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
@@ -11,18 +13,22 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
HELEKET_ENABLE, HELEKET_MERCHANT_ID, HELEKET_API_KEY,
|
||||
HELEKET_SUCCESS_URL, HELEKET_RETURN_URL, HELEKET_CALLBACK_URL,
|
||||
HELEKET_CURRENCY_RATE
|
||||
)
|
||||
|
||||
from handlers.buttons import BACK, PAY_2, HELEKET_CRYPTO
|
||||
from handlers.texts import (
|
||||
HELEKET_CRYPTO_DESCRIPTION, HELEKET_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
|
||||
HELEKET_API_KEY,
|
||||
HELEKET_CALLBACK_URL,
|
||||
HELEKET_MERCHANT_ID,
|
||||
HELEKET_RETURN_URL,
|
||||
HELEKET_SUCCESS_URL,
|
||||
PROVIDERS_ENABLED,
|
||||
)
|
||||
from handlers.payments.providers import get_providers
|
||||
from ..currency_rates import get_rub_rate
|
||||
from handlers.buttons import BACK, HELEKET, PAY_2
|
||||
from handlers.texts import ENTER_SUM, HELEKET_CRYPTO_DESCRIPTION, HELEKET_PAYMENT_MESSAGE, PAYMENT_OPTIONS
|
||||
from handlers.utils import edit_or_send_message
|
||||
from database import add_payment, async_session_maker
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -33,23 +39,35 @@ class ReplenishBalanceHeleket(StatesGroup):
|
||||
entering_custom_amount = State()
|
||||
|
||||
|
||||
PROVIDERS = get_providers(PROVIDERS_ENABLED)
|
||||
HELEKET_PAYMENT_METHODS = [
|
||||
{"enable": HELEKET_ENABLE, "currency": "USD", "to_currency": None, "name": "crypto", "button": HELEKET_CRYPTO, "desc": HELEKET_CRYPTO_DESCRIPTION},
|
||||
{
|
||||
"enable": bool(PROVIDERS.get("HELEKET", {}).get("enabled")),
|
||||
"currency": (PROVIDERS.get("HELEKET", {}).get("currency") or "USD"),
|
||||
"to_currency": None,
|
||||
"name": "crypto",
|
||||
"button": HELEKET,
|
||||
"desc": HELEKET_CRYPTO_DESCRIPTION,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_heleket")
|
||||
async def process_callback_pay_heleket(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None):
|
||||
@router.callback_query(F.data == "pay_heleket_crypto")
|
||||
async def process_callback_pay_heleket(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None
|
||||
):
|
||||
try:
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated Heleket payment.")
|
||||
|
||||
await state.clear()
|
||||
|
||||
if method_name:
|
||||
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None)
|
||||
if not method:
|
||||
await callback_query.message.delete()
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback_query.message.answer(
|
||||
text="Ошибка: выбранный способ оплаты недоступен.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
@@ -62,24 +80,27 @@ async def process_callback_pay_heleket(callback_query: types.CallbackQuery, stat
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
await callback_query.message.delete()
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
new_msg = await callback_query.message.answer(
|
||||
text=method["desc"],
|
||||
reply_markup=builder.as_markup(),
|
||||
@@ -96,10 +117,15 @@ async def process_callback_pay_heleket(callback_query: types.CallbackQuery, stat
|
||||
builder = InlineKeyboardBuilder()
|
||||
for method in HELEKET_PAYMENT_METHODS:
|
||||
if method["enable"]:
|
||||
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f'heleket_method|{method["name"]}'))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=method["button"], callback_data=f"heleket_method|{method['name']}")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
await callback_query.message.delete()
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
new_msg = await callback_query.message.answer(
|
||||
text="Выберите способ оплаты через Heleket:",
|
||||
reply_markup=builder.as_markup(),
|
||||
@@ -116,7 +142,7 @@ async def process_callback_pay_heleket(callback_query: types.CallbackQuery, stat
|
||||
async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
method_name = callback_query.data.split("|")[1]
|
||||
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None)
|
||||
|
||||
|
||||
if not method or not method["enable"]:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -125,32 +151,32 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(heleket_method=method_name)
|
||||
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket_crypto"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=method["desc"],
|
||||
@@ -165,10 +191,10 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
|
||||
async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
method_name = callback_query.data.split("|")[1]
|
||||
await state.update_data(heleket_method=method_name)
|
||||
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_heleket_{method_name}"))
|
||||
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=ENTER_SUM,
|
||||
@@ -183,7 +209,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
method_name = data.get("heleket_method")
|
||||
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None)
|
||||
|
||||
|
||||
if not method or not method["enable"]:
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
@@ -192,12 +218,12 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
amount = int(message.text.strip())
|
||||
if amount <= 0:
|
||||
raise ValueError
|
||||
if amount < 10:
|
||||
if amount < 10:
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="Минимальная сумма для оплаты криптовалютой — 10 рублей.",
|
||||
@@ -213,24 +239,77 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
payment_url = await generate_heleket_payment_link(amount, message.chat.id, method)
|
||||
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="balance")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=HELEKET_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation)
|
||||
|
||||
|
||||
async def process_fast_flow_heleket(
|
||||
callback_query: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
amount: int,
|
||||
method_name: str = "crypto",
|
||||
):
|
||||
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None)
|
||||
if not method:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: выбранный способ оплаты недоступен.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount <= 0:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Некорректная сумма.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
if amount < 10:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Минимальная сумма для оплаты криптовалютой — 10 рублей.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(heleket_method=method_name, amount=amount)
|
||||
payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method)
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="balance")],
|
||||
]
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=HELEKET_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation)
|
||||
|
||||
|
||||
@@ -239,9 +318,9 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
parts = callback_query.data.split("|")
|
||||
method_name = parts[1]
|
||||
amount_str = parts[2]
|
||||
|
||||
|
||||
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None)
|
||||
|
||||
|
||||
if not method or not method["enable"]:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -250,7 +329,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
if amount <= 0:
|
||||
@@ -263,68 +342,84 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method)
|
||||
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="balance")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=HELEKET_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation)
|
||||
|
||||
|
||||
async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) -> str:
|
||||
"""
|
||||
Создание платежа в Heleket и получение ссылки на оплату
|
||||
Создание платежа в Heleket и получение ссылки на оплату.
|
||||
amount — сумма в RUB, method['currency'] — валюта провайдера (обычно USD).
|
||||
"""
|
||||
url = "https://api.heleket.com/v1/payment"
|
||||
|
||||
unique_order_id = f"{int(time.time())}_{tg_id}"
|
||||
|
||||
usd_amount = round(amount / HELEKET_CURRENCY_RATE, 2)
|
||||
|
||||
data = {
|
||||
"amount": str(usd_amount),
|
||||
"currency": method["currency"],
|
||||
"order_id": unique_order_id,
|
||||
"url_success": HELEKET_SUCCESS_URL,
|
||||
"url_return": HELEKET_RETURN_URL,
|
||||
"url_callback": HELEKET_CALLBACK_URL,
|
||||
"additional_data": f"tg_id:{tg_id},rub_amount:{amount}",
|
||||
}
|
||||
|
||||
if method.get("to_currency"):
|
||||
data["to_currency"] = method["to_currency"]
|
||||
|
||||
json_data = json.dumps(data, separators=(',', ':'))
|
||||
base64_data = base64.b64encode(json_data.encode('utf-8')).decode('utf-8')
|
||||
sign_string = base64_data + HELEKET_API_KEY
|
||||
signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest()
|
||||
|
||||
headers = {
|
||||
"merchant": HELEKET_MERCHANT_ID,
|
||||
"sign": signature,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
pay_cur = str(method["currency"]).upper()
|
||||
|
||||
if pay_cur == "RUB":
|
||||
payment_amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
else:
|
||||
rate = await get_rub_rate(pay_cur, session=session)
|
||||
payment_amount = (Decimal(str(amount)) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
async with async_session_maker() as dbs:
|
||||
await add_payment(
|
||||
session=dbs,
|
||||
tg_id=tg_id,
|
||||
amount=float(amount),
|
||||
payment_system="HELEKET",
|
||||
status="pending",
|
||||
currency="RUB",
|
||||
payment_id=unique_order_id,
|
||||
)
|
||||
|
||||
data = {
|
||||
"amount": str(payment_amount),
|
||||
"currency": method["currency"],
|
||||
"order_id": unique_order_id,
|
||||
"url_success": HELEKET_SUCCESS_URL,
|
||||
"url_return": HELEKET_RETURN_URL,
|
||||
"url_callback": HELEKET_CALLBACK_URL,
|
||||
"additional_data": f"tg_id:{tg_id},rub_amount:{amount}",
|
||||
}
|
||||
if method.get("to_currency"):
|
||||
data["to_currency"] = method["to_currency"]
|
||||
|
||||
json_data = json.dumps(data, separators=(",", ":"))
|
||||
base64_data = base64.b64encode(json_data.encode("utf-8")).decode("utf-8")
|
||||
sign_string = base64_data + HELEKET_API_KEY
|
||||
signature = hashlib.md5(sign_string.encode("utf-8")).hexdigest()
|
||||
|
||||
headers = {
|
||||
"merchant": HELEKET_MERCHANT_ID,
|
||||
"sign": signature,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with session.post(url, headers=headers, data=json_data, timeout=60) as resp:
|
||||
if resp.status == 200:
|
||||
try:
|
||||
resp_json = await resp.json()
|
||||
if resp_json.get("state") == 0:
|
||||
if resp_json.get("state") == 0:
|
||||
payment_url = resp_json.get("result", {}).get("url")
|
||||
if payment_url:
|
||||
logger.info(f"Heleket payment URL created for user {tg_id}")
|
||||
@@ -350,4 +445,4 @@ async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) -
|
||||
return "https://heleket.com/"
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Heleket payment: {e}")
|
||||
return "https://heleket.com/"
|
||||
return "https://heleket.com/"
|
||||
@@ -1,7 +1,8 @@
|
||||
import aiohttp
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import aiohttp
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
@@ -10,14 +11,23 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
KASSAI_ENABLE, KASSAI_API_KEY, KASSAI_SECRET_KEY, KASSAI_DOMAIN, KASSAI_SHOP_ID,
|
||||
KASSAI_IP, KASSAI_SUCCESS_URL, KASSAI_FAILURE_URL
|
||||
KASSAI_API_KEY,
|
||||
KASSAI_DOMAIN,
|
||||
KASSAI_FAILURE_URL,
|
||||
KASSAI_IP,
|
||||
KASSAI_SECRET_KEY,
|
||||
KASSAI_SHOP_ID,
|
||||
KASSAI_SUCCESS_URL,
|
||||
PROVIDERS_ENABLED,
|
||||
)
|
||||
|
||||
from handlers.buttons import BACK, PAY_2, KASSAI_CARDS, KASSAI_SBP
|
||||
from handlers.payments.providers import get_providers
|
||||
from handlers.buttons import BACK, KASSAI_CARDS, KASSAI_SBP, PAY_2
|
||||
from handlers.texts import (
|
||||
KASSAI_CARDS_DESCRIPTION, KASSAI_SBP_DESCRIPTION,
|
||||
KASSAI_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
|
||||
ENTER_SUM,
|
||||
KASSAI_CARDS_DESCRIPTION,
|
||||
KASSAI_PAYMENT_MESSAGE,
|
||||
KASSAI_SBP_DESCRIPTION,
|
||||
PAYMENT_OPTIONS,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
@@ -32,20 +42,35 @@ class ReplenishBalanceKassaiState(StatesGroup):
|
||||
entering_custom_amount = State()
|
||||
|
||||
|
||||
PROVIDERS = get_providers(PROVIDERS_ENABLED)
|
||||
KASSAI_PAYMENT_METHODS = [
|
||||
{"enable": KASSAI_ENABLE, "method": 36, "name": "cards", "button": KASSAI_CARDS, "desc": KASSAI_CARDS_DESCRIPTION},
|
||||
{"enable": KASSAI_ENABLE, "method": 44, "name": "sbp", "button": KASSAI_SBP, "desc": KASSAI_SBP_DESCRIPTION},
|
||||
{
|
||||
"enable": bool(PROVIDERS.get("KASSAI_CARDS", {}).get("enabled")),
|
||||
"method": 36,
|
||||
"name": "cards",
|
||||
"button": KASSAI_CARDS,
|
||||
"desc": KASSAI_CARDS_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
"enable": bool(PROVIDERS.get("KASSAI_SBP", {}).get("enabled")),
|
||||
"method": 44,
|
||||
"name": "sbp",
|
||||
"button": KASSAI_SBP,
|
||||
"desc": KASSAI_SBP_DESCRIPTION,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai")
|
||||
async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None):
|
||||
async def process_callback_pay_kassai(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None
|
||||
):
|
||||
try:
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated KassaAI payment.")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
if method_name:
|
||||
method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None)
|
||||
if not method:
|
||||
@@ -54,45 +79,42 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
|
||||
except Exception:
|
||||
pass
|
||||
await callback_query.message.answer(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: выбранный способ оплаты недоступен.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
new_msg = await callback_query.message.answer(
|
||||
target_message=callback_query.message,
|
||||
text=method["desc"],
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
kassai_method=method_name,
|
||||
message_id=new_msg.message_id,
|
||||
@@ -100,25 +122,24 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
|
||||
)
|
||||
await state.set_state(ReplenishBalanceKassaiState.choosing_amount)
|
||||
return
|
||||
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for method in KASSAI_PAYMENT_METHODS:
|
||||
if method["enable"]:
|
||||
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f'kassai_method|{method["name"]}'))
|
||||
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f"kassai_method|{method['name']}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
new_msg = await callback_query.message.answer(
|
||||
target_message=callback_query.message,
|
||||
text="Выберите способ оплаты через KassaAI:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.update_data(message_id=new_msg.message_id, chat_id=new_msg.chat.id)
|
||||
await state.set_state(ReplenishBalanceKassaiState.choosing_method)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in process_callback_pay_kassai for user {callback_query.message.chat.id}: {e}")
|
||||
await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True)
|
||||
@@ -128,7 +149,7 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
|
||||
async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
method_name = callback_query.data.split("|")[1]
|
||||
method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None)
|
||||
|
||||
|
||||
if not method or not method["enable"]:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -137,32 +158,32 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(kassai_method=method_name)
|
||||
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
|
||||
callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_kassai"))
|
||||
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=method["desc"],
|
||||
@@ -177,10 +198,10 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
|
||||
async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
method_name = callback_query.data.split("|")[1]
|
||||
await state.update_data(kassai_method=method_name)
|
||||
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_kassai_{method_name}"))
|
||||
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=ENTER_SUM,
|
||||
@@ -195,7 +216,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
method_name = data.get("kassai_method")
|
||||
method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None)
|
||||
|
||||
|
||||
if not method or not method["enable"]:
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
@@ -204,7 +225,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
amount = int(message.text.strip())
|
||||
if amount <= 0:
|
||||
@@ -225,24 +246,24 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
payment_url = await generate_kassai_payment_link(amount, message.chat.id, method)
|
||||
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="balance")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_message = await edit_or_send_message(
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=KASSAI_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation)
|
||||
|
||||
|
||||
@@ -251,9 +272,9 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
parts = callback_query.data.split("|")
|
||||
method_name = parts[1]
|
||||
amount_str = parts[2]
|
||||
|
||||
|
||||
method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None)
|
||||
|
||||
|
||||
if not method or not method["enable"]:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -262,7 +283,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
if amount <= 0:
|
||||
@@ -275,24 +296,24 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
payment_url = await generate_kassai_payment_link(amount, callback_query.message.chat.id, method)
|
||||
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="balance")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_message = await edit_or_send_message(
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=KASSAI_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation)
|
||||
|
||||
|
||||
@@ -303,14 +324,12 @@ async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) ->
|
||||
nonce = int(time.time())
|
||||
unique_payment_id = f"{nonce}_{tg_id}"
|
||||
url = "https://api.fk.life/v1/orders/create"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
client_email = f"{tg_id}@{KASSAI_DOMAIN}"
|
||||
client_ip = KASSAI_IP
|
||||
|
||||
|
||||
data_for_signature = {
|
||||
"shopId": KASSAI_SHOP_ID,
|
||||
"nonce": nonce,
|
||||
@@ -321,22 +340,14 @@ async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) ->
|
||||
"currency": "RUB",
|
||||
"success_url": KASSAI_SUCCESS_URL,
|
||||
"failure_url": KASSAI_FAILURE_URL,
|
||||
"paymentId": unique_payment_id
|
||||
"paymentId": unique_payment_id,
|
||||
}
|
||||
|
||||
sorted_keys = sorted(data_for_signature.keys())
|
||||
values = [str(data_for_signature[key]) for key in sorted_keys]
|
||||
sign_string = "|".join(values)
|
||||
|
||||
signature = hmac.new(
|
||||
KASSAI_API_KEY.encode('utf-8'),
|
||||
sign_string.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
data = data_for_signature.copy()
|
||||
data["signature"] = signature
|
||||
|
||||
|
||||
sign_string = "|".join(str(data_for_signature[k]) for k in sorted(data_for_signature.keys()))
|
||||
signature = hmac.new(KASSAI_API_KEY.encode("utf-8"), sign_string.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
data = {**data_for_signature, "signature": signature}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=data, timeout=60) as resp:
|
||||
@@ -348,12 +359,10 @@ async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) ->
|
||||
if payment_url:
|
||||
logger.info(f"KassaAI payment URL created for user {tg_id}")
|
||||
return payment_url
|
||||
else:
|
||||
logger.error(f"KassaAI: No location in response: {resp_json}")
|
||||
return "https://fk.life/"
|
||||
else:
|
||||
logger.error(f"KassaAI: Unsuccessful response: {resp_json}")
|
||||
logger.error(f"KassaAI: No location in response: {resp_json}")
|
||||
return "https://fk.life/"
|
||||
logger.error(f"KassaAI: Unsuccessful response: {resp_json}")
|
||||
return "https://fk.life/"
|
||||
except Exception as e:
|
||||
logger.error(f"KassaAI: Error parsing JSON response: {e}")
|
||||
text = await resp.text()
|
||||
@@ -374,26 +383,17 @@ async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) ->
|
||||
|
||||
def verify_kassai_signature(data: dict, signature: str) -> bool:
|
||||
"""
|
||||
Проверка подписи вебхука KassaAI согласно документации FreeKassa
|
||||
Формат: MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID
|
||||
Проверка подписи вебхука FreeKassa (используемой KassaAI):
|
||||
MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID
|
||||
"""
|
||||
try:
|
||||
sign_string = (
|
||||
f"{KASSAI_SHOP_ID}:"
|
||||
f"{data.get('AMOUNT', '')}:"
|
||||
f"{KASSAI_SECRET_KEY}:"
|
||||
f"{data.get('MERCHANT_ORDER_ID', '')}"
|
||||
)
|
||||
|
||||
expected_signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest()
|
||||
|
||||
sign_string = f"{KASSAI_SHOP_ID}:{data.get('AMOUNT', '')}:{KASSAI_SECRET_KEY}:{data.get('MERCHANT_ORDER_ID', '')}"
|
||||
expected_signature = hashlib.md5(sign_string.encode("utf-8")).hexdigest()
|
||||
result = signature.upper() == expected_signature.upper()
|
||||
|
||||
if not result:
|
||||
logger.error(f"KassaAI signature mismatch. Expected: {expected_signature}, Got: {signature}")
|
||||
logger.error(f"Sign string: {sign_string}")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи KassaAI: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -0,0 +1,96 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RENEWAL_PRICES
|
||||
from handlers.buttons import BACK, CUSTOM_AMOUNT
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
|
||||
|
||||
async def payment_options_for_user(
|
||||
db_session,
|
||||
tg_id: int,
|
||||
language_code: str | None,
|
||||
*,
|
||||
force_currency: str | None = None,
|
||||
) -> list[dict]:
|
||||
items = []
|
||||
for price_rub in RENEWAL_PRICES.values():
|
||||
txt = await format_for_user(
|
||||
db_session,
|
||||
tg_id,
|
||||
price_rub,
|
||||
language_code,
|
||||
force_currency=force_currency,
|
||||
)
|
||||
items.append({"text": txt, "callback_data": f"amount|{int(price_rub)}"})
|
||||
return items
|
||||
|
||||
|
||||
def payment_options(currency: str = "RUB") -> list[dict]:
|
||||
return [{"text": f"{price} {currency}", "callback_data": f"amount|{price}"} for price in RENEWAL_PRICES.values()]
|
||||
|
||||
|
||||
def build_amounts_keyboard(
|
||||
*,
|
||||
prefix: str,
|
||||
pattern: str,
|
||||
back_cb: str = "balance",
|
||||
custom_cb: str | tuple[str, str] | None = None,
|
||||
per_row: int = 2,
|
||||
opts: Iterable[dict] | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
items = list(opts) if opts is not None else payment_options()
|
||||
b = InlineKeyboardBuilder()
|
||||
row = []
|
||||
for i, item in enumerate(items, 1):
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=item["text"],
|
||||
callback_data=pattern.format(prefix=prefix, price=item["callback_data"].split("|", 1)[-1]),
|
||||
)
|
||||
)
|
||||
if i % per_row == 0:
|
||||
b.row(*row)
|
||||
row = []
|
||||
if row:
|
||||
b.row(*row)
|
||||
if custom_cb:
|
||||
cb = custom_cb[1] if isinstance(custom_cb, tuple) else custom_cb
|
||||
b.row(InlineKeyboardButton(text=CUSTOM_AMOUNT, callback_data=cb))
|
||||
b.row(InlineKeyboardButton(text=BACK, callback_data=back_cb))
|
||||
return b.as_markup()
|
||||
|
||||
|
||||
def parse_amount_from_callback(data: str, *, prefixes: list[str]) -> int | None:
|
||||
for p in prefixes:
|
||||
if data.startswith(f"{p}_amount|"):
|
||||
try:
|
||||
return int(data.split("|", 1)[1])
|
||||
except Exception:
|
||||
return None
|
||||
if data.startswith(f"{p}|amount|"):
|
||||
try:
|
||||
return int(data.rsplit("|", 1)[-1])
|
||||
except Exception:
|
||||
return None
|
||||
if data.startswith(f"{p}|"):
|
||||
try:
|
||||
return int(data.split("|", 1)[1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def pay_keyboard(url: str, *, pay_text: str, back_cb: str) -> InlineKeyboardMarkup:
|
||||
b = InlineKeyboardBuilder()
|
||||
b.row(InlineKeyboardButton(text=pay_text, url=url))
|
||||
b.row(InlineKeyboardButton(text=BACK, callback_data=back_cb))
|
||||
return b.as_markup()
|
||||
|
||||
|
||||
def back_keyboard(back_cb: str) -> InlineKeyboardMarkup:
|
||||
b = InlineKeyboardBuilder()
|
||||
b.row(InlineKeyboardButton(text=BACK, callback_data=back_cb))
|
||||
return b.as_markup()
|
||||
@@ -0,0 +1,202 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import MULTICURRENCY_ENABLE, DONATIONS_ENABLE, PROVIDERS_ENABLED
|
||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
|
||||
from database import get_last_payments
|
||||
from database.models import User
|
||||
from handlers import buttons as btn
|
||||
|
||||
from handlers.payments.heleket.heleket import process_callback_pay_heleket
|
||||
from handlers.payments.kassai.kassai import process_callback_pay_kassai
|
||||
from handlers.payments.stars.handlers import process_callback_pay_stars
|
||||
from handlers.payments.tribute.handlers import process_callback_pay_tribute
|
||||
from handlers.payments.wata.wata import process_callback_pay_wata
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.currency_flow import build_currency_choice_kb
|
||||
from handlers.payments.providers import get_providers_with_hooks
|
||||
|
||||
from ..utils import edit_or_send_message
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay")
|
||||
async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
payment_handlers = []
|
||||
|
||||
for key, cfg in PROVIDERS.items():
|
||||
if not cfg.get("enabled"):
|
||||
continue
|
||||
val = cfg.get("value")
|
||||
if not val:
|
||||
continue
|
||||
fn = globals().get(f"process_callback_{val}")
|
||||
if callable(fn):
|
||||
payment_handlers.append(fn)
|
||||
|
||||
module_buttons = await run_hooks("pay_menu_buttons", chat_id=callback_query.from_user.id, admin=False, session=session)
|
||||
has_extra_menu_items = bool(module_buttons) or bool(DONATIONS_ENABLE) or PROVIDERS.get("TRIBUTE", {}).get("enabled")
|
||||
|
||||
if MULTICURRENCY_ENABLE:
|
||||
kb = build_currency_choice_kb(show_stars=bool(PROVIDERS.get("STARS", {}).get("enabled")), prefix="pay_currency")
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=FAST_PAY_CHOOSE_CURRENCY,
|
||||
reply_markup=kb.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
if len(payment_handlers) == 1 and not has_extra_menu_items:
|
||||
return await payment_handlers[0](callback_query, state, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for key, cfg in PROVIDERS.items():
|
||||
if not cfg.get("enabled"):
|
||||
continue
|
||||
text = getattr(btn, key, key)
|
||||
builder.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
||||
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(target_message=callback_query.message, text=PAYMENT_METHODS_MSG, reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
async def _build_pay_menu_for_currency(currency: str) -> InlineKeyboardBuilder:
|
||||
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
|
||||
b = InlineKeyboardBuilder()
|
||||
for key, cfg in PROVIDERS.items():
|
||||
if not cfg.get("enabled"):
|
||||
continue
|
||||
if cfg.get("currency") != currency:
|
||||
continue
|
||||
text = getattr(btn, key, key)
|
||||
b.row(InlineKeyboardButton(text=text, callback_data=cfg["value"]))
|
||||
return b
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_currency|"))
|
||||
async def handle_pay_currency(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
currency = callback_query.data.split("|")[1]
|
||||
|
||||
if currency == "STARS":
|
||||
return await process_callback_pay_stars(callback_query, state, session)
|
||||
|
||||
base_builder = await _build_pay_menu_for_currency(currency)
|
||||
module_buttons = await run_hooks("pay_menu_buttons", chat_id=callback_query.from_user.id, admin=False, session=session)
|
||||
builder = insert_hook_buttons(base_builder, module_buttons)
|
||||
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_currency"))
|
||||
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(target_message=callback_query.message, text=PAYMENT_METHODS_MSG, reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
|
||||
stmt = select(User.balance).where(User.tg_id == callback_query.from_user.id)
|
||||
result = await session.execute(stmt)
|
||||
balance_rub = result.scalar_one_or_none() or 0.0
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
balance_text = await format_for_user(session, callback_query.from_user.id, balance_rub, language_code)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=btn.PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=btn.BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(InlineKeyboardButton(text=btn.COUPON, callback_data="activate_coupon"))
|
||||
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance_text)
|
||||
image_path = os.path.join("img", "pay.jpg")
|
||||
|
||||
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=builder.as_markup(), media_path=image_path, disable_web_page_preview=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance_history")
|
||||
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=btn.PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
records = await get_last_payments(session, callback_query.from_user.id, statuses=["success"])
|
||||
|
||||
if records:
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
history_text = "<b>💳 История операций:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
amount_rub = record["amount"] or 0
|
||||
formatted_amount = await format_for_user(session, callback_query.from_user.id, amount_rub, language_code)
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
history_text += f"Сумма: {formatted_amount}\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
|
||||
history_text += "</blockquote>"
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
await edit_or_send_message(target_message=callback_query.message, text=history_text, reply_markup=builder.as_markup(), media_path=None, disable_web_page_preview=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_currency")
|
||||
async def back_to_currency(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
kb = build_currency_choice_kb(show_stars=True, prefix="pay_currency")
|
||||
kb.row(InlineKeyboardButton(text=btn.BACK, callback_data="back_to_pay"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=FAST_PAY_CHOOSE_CURRENCY,
|
||||
reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata_ru")
|
||||
async def handle_pay_wata_ru(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_wata(callback_query, state, session, cassa_name="ru")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata_sbp")
|
||||
async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_wata(callback_query, state, session, cassa_name="sbp")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata_int")
|
||||
async def handle_pay_wata_int(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_wata(callback_query, state, session, cassa_name="int")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_cards")
|
||||
async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="cards")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_sbp")
|
||||
async def handle_pay_kassai_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="sbp")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_heleket_crypto")
|
||||
async def handle_pay_heleket_crypto(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_heleket(callback_query, state, session, method_name="crypto")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_tribute")
|
||||
async def handle_pay_tribute(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await process_callback_pay_tribute(callback_query, state, session)
|
||||
@@ -0,0 +1,93 @@
|
||||
from typing import Dict
|
||||
from hooks.hooks import run_hooks
|
||||
|
||||
PROVIDERS_BASE: Dict[str, dict] = {
|
||||
"YOOKASSA": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_yookassa",
|
||||
"fast": "process_custom_amount_input",
|
||||
},
|
||||
"YOOMONEY": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_yoomoney",
|
||||
"fast": "process_custom_amount_input_yoomoney",
|
||||
},
|
||||
"ROBOKASSA": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_robokassa",
|
||||
"fast": "handle_custom_amount_input",
|
||||
},
|
||||
"KASSAI_CARDS": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_kassai_cards",
|
||||
"fast": None,
|
||||
},
|
||||
"KASSAI_SBP": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_kassai_sbp",
|
||||
"fast": None,
|
||||
},
|
||||
"WATA_RU": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_wata_ru",
|
||||
"fast": None,
|
||||
},
|
||||
"WATA_SBP": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_wata_sbp",
|
||||
"fast": None,
|
||||
},
|
||||
"TRIBUTE": {
|
||||
"currency": "RUB",
|
||||
"value": "pay_tribute",
|
||||
"fast": None,
|
||||
},
|
||||
"HELEKET": {
|
||||
"currency": "USD",
|
||||
"value": "pay_heleket_crypto",
|
||||
"fast": None,
|
||||
},
|
||||
"CRYPTOBOT": {
|
||||
"currency": "USD",
|
||||
"value": "pay_cryptobot",
|
||||
"fast": "process_custom_amount_input",
|
||||
},
|
||||
"FREEKASSA": {
|
||||
"currency": "USD",
|
||||
"value": "pay_freekassa",
|
||||
"fast": None,
|
||||
},
|
||||
"WATA_INT": {
|
||||
"currency": "USD",
|
||||
"value": "pay_wata_int",
|
||||
"fast": None,
|
||||
},
|
||||
"STARS": {
|
||||
"currency": "STARS",
|
||||
"value": "pay_stars",
|
||||
"fast": "process_custom_amount_input_stars",
|
||||
},
|
||||
}
|
||||
|
||||
def get_providers(flags: Dict[str, bool]) -> Dict[str, dict]:
|
||||
out: Dict[str, dict] = {}
|
||||
for k, base in PROVIDERS_BASE.items():
|
||||
cfg = dict(base)
|
||||
cfg["enabled"] = bool(flags.get(k))
|
||||
out[k] = cfg
|
||||
return out
|
||||
|
||||
async def get_providers_with_hooks(flags: Dict[str, bool]) -> Dict[str, dict]:
|
||||
out = get_providers(flags)
|
||||
results = await run_hooks("providers_config", providers=out, flags=flags)
|
||||
for r in results:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
for name, patch in r.items():
|
||||
if patch is None:
|
||||
out.pop(name, None)
|
||||
elif isinstance(patch, dict):
|
||||
base = dict(out.get(name, {}))
|
||||
base.update(patch)
|
||||
out[name] = base
|
||||
return out
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
@@ -0,0 +1,162 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import add_user, check_user_exists, get_key_count, get_temporary_data
|
||||
from handlers.buttons import CUSTOM_AMOUNT, PAY_2, MAIN_MENU
|
||||
from handlers.payments.keyboards import (
|
||||
back_keyboard,
|
||||
build_amounts_keyboard,
|
||||
parse_amount_from_callback,
|
||||
pay_keyboard as build_pay_keyboard,
|
||||
payment_options_for_user,
|
||||
)
|
||||
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from .service import create_and_store_robokassa_payment
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_robokassa = State()
|
||||
waiting_for_payment_confirmation_robokassa = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_robokassa")
|
||||
async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
b = await get_key_count(session, tg_id)
|
||||
if b == 0 and not await check_user_exists(session, tg_id):
|
||||
u = callback_query.from_user
|
||||
await add_user(
|
||||
tg_id=u.id,
|
||||
username=u.username,
|
||||
first_name=u.first_name,
|
||||
last_name=u.last_name,
|
||||
language_code=u.language_code,
|
||||
is_bot=u.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB")
|
||||
|
||||
markup = build_amounts_keyboard(
|
||||
prefix="robokassa",
|
||||
pattern="{prefix}_amount|{price}",
|
||||
back_cb="balance",
|
||||
custom_cb=(CUSTOM_AMOUNT, "enter_custom_amount_robokassa"),
|
||||
per_row=2,
|
||||
opts=opts,
|
||||
)
|
||||
|
||||
await callback_query.message.delete()
|
||||
m = await callback_query.message.answer(text="Выберите сумму пополнения:", reply_markup=markup)
|
||||
await state.update_data(message_id=m.message_id, chat_id=m.chat.id)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("robokassa_"))
|
||||
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
amount = parse_amount_from_callback(callback_query.data, prefixes=["robokassa"])
|
||||
if not amount or amount <= 0:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Некорректная сумма.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = callback_query.message.chat.id
|
||||
url, _ = await create_and_store_robokassa_payment(session, tg_id, amount, "Пополнение баланса", inv_id=0)
|
||||
|
||||
kb = build_pay_keyboard(
|
||||
url,
|
||||
pay_text=PAY_2,
|
||||
back_cb="pay_robokassa",
|
||||
)
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text),
|
||||
reply_markup=kb,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_robokassa")
|
||||
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
b = back_keyboard("pay_robokassa")
|
||||
await edit_or_send_message(target_message=callback_query.message, text=ENTER_SUM, reply_markup=b, force_text=True)
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
|
||||
|
||||
async def handle_custom_amount_input(
|
||||
event: types.Message | types.CallbackQuery,
|
||||
session: AsyncSession,
|
||||
pay_button_text: str = PAY_2,
|
||||
main_menu_text: str = MAIN_MENU,
|
||||
):
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
message = event.message
|
||||
from_user = event.from_user
|
||||
tg_id = from_user.id
|
||||
temp_data = await get_temporary_data(session, tg_id)
|
||||
if not temp_data or temp_data["state"] not in ["waiting_for_payment", "waiting_for_renewal_payment", "waiting_for_gift_payment"]:
|
||||
await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.")
|
||||
return
|
||||
amount = int(temp_data["data"].get("required_amount", 0))
|
||||
if amount <= 0:
|
||||
await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.")
|
||||
return
|
||||
else:
|
||||
message = event
|
||||
from_user = message.from_user
|
||||
tg_id = from_user.id
|
||||
text = message.text
|
||||
if not text or not text.isdigit():
|
||||
await message.answer("Введите корректную сумму числом.")
|
||||
return
|
||||
amount = int(text)
|
||||
if amount <= 0:
|
||||
await message.answer("Сумма должна быть больше нуля.")
|
||||
return
|
||||
|
||||
try:
|
||||
url, _ = await create_and_store_robokassa_payment(session, tg_id, amount, "Пополнение баланса", inv_id=0)
|
||||
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=pay_button_text, url=url)],
|
||||
[InlineKeyboardButton(text=main_menu_text, callback_data="profile")],
|
||||
]
|
||||
)
|
||||
|
||||
language_code = getattr(from_user, "language_code", None)
|
||||
amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB")
|
||||
text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text)
|
||||
|
||||
await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup)
|
||||
except Exception as e:
|
||||
from logger import logger as _lg
|
||||
_lg.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE
|
||||
from database import add_payment
|
||||
|
||||
|
||||
def _build_receipt(amount: float, sno: str = "usn_income") -> dict:
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"name": "Пополнение баланса",
|
||||
"quantity": 1,
|
||||
"sum": float(amount),
|
||||
"payment_method": "full_payment",
|
||||
"payment_object": "payment",
|
||||
"tax": "none",
|
||||
}
|
||||
],
|
||||
"sno": sno,
|
||||
}
|
||||
|
||||
|
||||
def _format_amount(amount: float | int) -> str:
|
||||
s = str(Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_DOWN))
|
||||
return s.rstrip("0").rstrip(".") if "." in s else s
|
||||
|
||||
|
||||
def generate_payment_link(amount: int | float, inv_id: int, description: str, tg_id: int) -> tuple[str, str]:
|
||||
out_sum = _format_amount(amount)
|
||||
receipt_json = json.dumps(_build_receipt(amount), ensure_ascii=False, separators=(",", ":"))
|
||||
receipt_enc = quote_plus(receipt_json, safe="")
|
||||
pid = str(uuid.uuid4())
|
||||
shp = {"Shp_id": str(tg_id), "Shp_pid": pid}
|
||||
base = f"{ROBOKASSA_LOGIN}:{out_sum}:{inv_id}:{receipt_enc}:{ROBOKASSA_PASSWORD1}"
|
||||
for k in sorted(shp.keys(), key=str.lower):
|
||||
base += f":{k}={shp[k]}"
|
||||
signature = hashlib.md5(base.encode("utf-8")).hexdigest().upper()
|
||||
query = {
|
||||
"MrchLogin": ROBOKASSA_LOGIN,
|
||||
"OutSum": out_sum,
|
||||
"InvId": inv_id,
|
||||
"Description": description,
|
||||
"Receipt": receipt_enc,
|
||||
"SignatureValue": signature,
|
||||
**shp,
|
||||
}
|
||||
if ROBOKASSA_TEST_MODE:
|
||||
query["IsTest"] = 1
|
||||
return "https://auth.robokassa.ru/Merchant/Index.aspx?" + urlencode(query), pid
|
||||
|
||||
|
||||
async def create_and_store_robokassa_payment(
|
||||
session: AsyncSession, tg_id: int, amount: int | float, description: str, inv_id: int = 0
|
||||
) -> tuple[str, str]:
|
||||
url, pid = generate_payment_link(amount, inv_id, description, tg_id)
|
||||
await add_payment(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
amount=float(amount),
|
||||
payment_system="robokassa",
|
||||
status="pending",
|
||||
currency="RUB",
|
||||
payment_id=pid,
|
||||
metadata=None,
|
||||
)
|
||||
return url, pid
|
||||
|
||||
|
||||
def check_payment_signature(params) -> bool:
|
||||
out_sum = params.get("OutSum") or params.get("out_summ") or params.get("outsumm")
|
||||
inv_id = params.get("InvId") or params.get("inv_id") or params.get("invid")
|
||||
received_sig = (params.get("SignatureValue") or params.get("signaturevalue") or "").upper()
|
||||
if not out_sum or not inv_id or not received_sig:
|
||||
return False
|
||||
shp_items = [(k, params[k]) for k in params.keys() if k.lower().startswith("shp_")]
|
||||
shp_items.sort(key=lambda kv: kv[0].lower())
|
||||
shp_suffix = "".join(f":{k}={v}" for k, v in shp_items)
|
||||
base = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}{shp_suffix}"
|
||||
expected_sig = hashlib.md5(base.encode("utf-8")).hexdigest().upper()
|
||||
return received_sig == expected_sig
|
||||
@@ -0,0 +1,53 @@
|
||||
from aiohttp import web
|
||||
|
||||
from database import add_payment, async_session_maker, get_payment_by_payment_id, update_balance, update_payment_status
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from logger import logger
|
||||
|
||||
from .service import check_payment_signature
|
||||
|
||||
|
||||
async def robokassa_webhook(request: web.Request):
|
||||
try:
|
||||
params = await request.post()
|
||||
if not check_payment_signature(params):
|
||||
return web.Response(status=400)
|
||||
|
||||
amount_raw = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
shp_id = params.get("Shp_id") or params.get("shp_id") or params.get("id")
|
||||
shp_pid = params.get("Shp_pid") or params.get("shp_pid") or params.get("pid")
|
||||
|
||||
if not amount_raw or not inv_id or not shp_id or not shp_pid:
|
||||
return web.Response(status=400)
|
||||
|
||||
tg_id = int(shp_id)
|
||||
amount = float(amount_raw)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
payment = await get_payment_by_payment_id(session, shp_pid)
|
||||
if payment:
|
||||
if payment.get("status") == "success":
|
||||
return web.Response(text=f"OK{inv_id}")
|
||||
ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success")
|
||||
if not ok:
|
||||
return web.Response(status=500)
|
||||
else:
|
||||
await add_payment(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
amount=amount,
|
||||
payment_system="ROBOKASSA",
|
||||
status="success",
|
||||
currency="RUB",
|
||||
payment_id=shp_pid,
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
await update_balance(session, tg_id, amount)
|
||||
await send_payment_success_notification(tg_id, amount, session)
|
||||
|
||||
return web.Response(text=f"OK{inv_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing ROBOKASSA webhook: {e}")
|
||||
return web.Response(status=500)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import aiohttp
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
@@ -7,17 +8,23 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
WATA_RU_ENABLE, WATA_RU_TOKEN,
|
||||
WATA_SBP_ENABLE, WATA_SBP_TOKEN,
|
||||
WATA_INT_ENABLE, WATA_INT_TOKEN,
|
||||
REDIRECT_LINK,
|
||||
FAIL_REDIRECT_LINK,
|
||||
REDIRECT_LINK,
|
||||
WATA_INT_TOKEN,
|
||||
WATA_RU_TOKEN,
|
||||
WATA_SBP_TOKEN,
|
||||
PROVIDERS_ENABLED,
|
||||
)
|
||||
|
||||
from handlers.buttons import BACK, PAY_2, WATA_RU, WATA_SBP, WATA_INT
|
||||
from handlers.payments.providers import get_providers
|
||||
from handlers.buttons import BACK, PAY_2, WATA_INT, WATA_RU, WATA_SBP
|
||||
from handlers.texts import (
|
||||
WATA_RU_DESCRIPTION, WATA_SBP_DESCRIPTION, WATA_INT_DESCRIPTION,
|
||||
WATA_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS, WATA_PAYMENT_TITLE
|
||||
ENTER_SUM,
|
||||
PAYMENT_OPTIONS,
|
||||
WATA_INT_DESCRIPTION,
|
||||
WATA_PAYMENT_MESSAGE,
|
||||
WATA_PAYMENT_TITLE,
|
||||
WATA_RU_DESCRIPTION,
|
||||
WATA_SBP_DESCRIPTION,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
@@ -30,20 +37,42 @@ class ReplenishBalanceWataState(StatesGroup):
|
||||
choosing_cassa = State()
|
||||
choosing_amount = State()
|
||||
waiting_for_payment_confirmation = State()
|
||||
entering_custom_amount = State()
|
||||
entering_custom_amount = State()
|
||||
|
||||
|
||||
PROVIDERS = get_providers(PROVIDERS_ENABLED)
|
||||
WATA_CASSA_CONFIG = [
|
||||
{"enable": WATA_RU_ENABLE, "token": WATA_RU_TOKEN, "name": "ru", "button": WATA_RU, "desc": WATA_RU_DESCRIPTION},
|
||||
{"enable": WATA_SBP_ENABLE, "token": WATA_SBP_TOKEN, "name": "sbp", "button": WATA_SBP, "desc": WATA_SBP_DESCRIPTION},
|
||||
{"enable": WATA_INT_ENABLE, "token": WATA_INT_TOKEN, "name": "int", "button": WATA_INT, "desc": WATA_INT_DESCRIPTION},
|
||||
{
|
||||
"enable": bool(PROVIDERS.get("WATA_RU", {}).get("enabled")),
|
||||
"token": WATA_RU_TOKEN,
|
||||
"name": "ru",
|
||||
"button": WATA_RU,
|
||||
"desc": WATA_RU_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
"enable": bool(PROVIDERS.get("WATA_SBP", {}).get("enabled")),
|
||||
"token": WATA_SBP_TOKEN,
|
||||
"name": "sbp",
|
||||
"button": WATA_SBP,
|
||||
"desc": WATA_SBP_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
"enable": bool(PROVIDERS.get("WATA_INT", {}).get("enabled")),
|
||||
"token": WATA_INT_TOKEN,
|
||||
"name": "int",
|
||||
"button": WATA_INT,
|
||||
"desc": WATA_INT_DESCRIPTION,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_wata")
|
||||
async def process_callback_pay_wata(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, cassa_name: str = None):
|
||||
async def process_callback_pay_wata(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, cassa_name: str = None
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated WATA payment.")
|
||||
|
||||
if cassa_name:
|
||||
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name and c["enable"]), None)
|
||||
if not cassa:
|
||||
@@ -54,29 +83,36 @@ async def process_callback_pay_wata(callback_query: types.CallbackQuery, state:
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
callback_data=f"wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]['callback_data']}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
callback_data=f"wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i + 1]['callback_data']}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
callback_data=f"wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]['callback_data']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"wata_custom_amount|{cassa_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
|
||||
await callback_query.message.delete()
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text=cassa["desc"],
|
||||
reply_markup=builder.as_markup(),
|
||||
@@ -84,12 +120,18 @@ async def process_callback_pay_wata(callback_query: types.CallbackQuery, state:
|
||||
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id, wata_cassa=cassa_name)
|
||||
await state.set_state(ReplenishBalanceWataState.choosing_amount)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cassa in WATA_CASSA_CONFIG:
|
||||
if cassa["enable"]:
|
||||
builder.row(InlineKeyboardButton(text=cassa["button"], callback_data=f'wata_cassa|{cassa["name"]}'))
|
||||
builder.row(InlineKeyboardButton(text=cassa["button"], callback_data=f"wata_cassa|{cassa['name']}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
await callback_query.message.delete()
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text="Выберите способ оплаты через WATA:",
|
||||
reply_markup=builder.as_markup(),
|
||||
@@ -101,8 +143,9 @@ async def process_callback_pay_wata(callback_query: types.CallbackQuery, state:
|
||||
@router.callback_query(F.data.startswith("wata_cassa|"))
|
||||
async def process_cassa_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
cassa_name = callback_query.data.split("|")[1]
|
||||
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name), None)
|
||||
if not cassa or not cassa["enable"]:
|
||||
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name and c["enable"]), None)
|
||||
|
||||
if not cassa:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: выбранная касса недоступна.",
|
||||
@@ -110,29 +153,36 @@ async def process_cassa_selection(callback_query: types.CallbackQuery, state: FS
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(wata_cassa=cassa_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
callback_data=f"wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]['callback_data']}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
callback_data=f"wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i + 1]['callback_data']}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
callback_data=f"wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]['callback_data']}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
await callback_query.message.delete()
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text=cassa["desc"],
|
||||
reply_markup=builder.as_markup(),
|
||||
@@ -161,8 +211,10 @@ async def handle_custom_amount_text_input(message: types.Message, state: FSMCont
|
||||
async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
cassa_name = callback_query.data.split("|")[1]
|
||||
await state.update_data(wata_cassa=cassa_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_wata_{cassa_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=ENTER_SUM,
|
||||
@@ -231,8 +283,9 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
parts = callback_query.data.split("|")
|
||||
cassa_name = parts[1]
|
||||
amount_str = parts[-1]
|
||||
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name), None)
|
||||
if not cassa or not cassa["enable"]:
|
||||
|
||||
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name and c["enable"]), None)
|
||||
if not cassa:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: выбранная касса недоступна.",
|
||||
@@ -240,6 +293,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
if amount <= 0:
|
||||
@@ -252,20 +306,24 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
force_text=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
payment_url = await generate_wata_payment_link(amount, callback_query.message.chat.id, cassa)
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="balance")],
|
||||
]
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=WATA_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceWataState.waiting_for_payment_confirmation)
|
||||
|
||||
|
||||
@@ -287,51 +345,49 @@ async def generate_wata_payment_link(amount, tg_id, cassa):
|
||||
if cassa["name"] == "int":
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
async def get_usd_rate():
|
||||
today = datetime.now().strftime("%d/%m/%Y")
|
||||
url = f"http://www.cbr.ru/scripts/XML_daily.asp?date_req={today}"
|
||||
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=15) as resp:
|
||||
if resp.status == 200:
|
||||
xml_content = await resp.text()
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
for valute in root.findall('Valute'):
|
||||
char_code = valute.find('CharCode')
|
||||
if char_code is not None and char_code.text == 'USD':
|
||||
value_elem = valute.find('Value')
|
||||
|
||||
for valute in root.findall("Valute"):
|
||||
char_code = valute.find("CharCode")
|
||||
if char_code is not None and char_code.text == "USD":
|
||||
value_elem = valute.find("Value")
|
||||
if value_elem is not None:
|
||||
|
||||
usd_rub_rate = float(value_elem.text.replace(',', '.'))
|
||||
|
||||
usd_rub_rate = float(value_elem.text.replace(",", "."))
|
||||
rub_usd_rate = 1 / usd_rub_rate
|
||||
logger.info(f"Successfully got USD rate from CBR: 1 USD = {usd_rub_rate} RUB, 1 RUB = {rub_usd_rate} USD")
|
||||
logger.info(
|
||||
f"CBR USD rate: 1 USD = {usd_rub_rate} RUB, 1 RUB = {rub_usd_rate} USD"
|
||||
)
|
||||
return rub_usd_rate
|
||||
|
||||
|
||||
logger.warning("USD rate not found in CBR response")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get USD rate from CBR: {e}")
|
||||
|
||||
|
||||
fallback_rate = 0.0105
|
||||
fallback_rate = 0.0105
|
||||
logger.warning(f"Using fallback USD rate: {fallback_rate}")
|
||||
return fallback_rate
|
||||
|
||||
|
||||
try:
|
||||
usd_rate = await get_usd_rate()
|
||||
rub_per_usd = 1 / usd_rate
|
||||
rub_per_usd_plus_5 = rub_per_usd + 5
|
||||
rub_per_usd_plus_5 = rub_per_usd + 5
|
||||
new_usd_rate = 1 / rub_per_usd_plus_5
|
||||
amount_usd = round(float(amount) * new_usd_rate, 2)
|
||||
data["amount"] = amount_usd
|
||||
data["currency"] = "USD"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert RUB to USD: {e}")
|
||||
|
||||
fallback_usd_rate = 0.0105
|
||||
rub_per_usd = 1 / fallback_usd_rate
|
||||
rub_per_usd_plus_5 = rub_per_usd + 5
|
||||
@@ -339,6 +395,7 @@ async def generate_wata_payment_link(amount, tg_id, cassa):
|
||||
amount_usd = round(float(amount) * new_usd_rate, 2)
|
||||
data["amount"] = amount_usd
|
||||
data["currency"] = "USD"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=data, timeout=60) as resp:
|
||||
if resp.status == 200:
|
||||
@@ -348,17 +405,17 @@ async def generate_wata_payment_link(amount, tg_id, cassa):
|
||||
text = await resp.text()
|
||||
logger.error(f"Ошибка при разборе JSON ответа WATA: статус={resp.status}, ответ={text}")
|
||||
return "https://wata.pro/"
|
||||
|
||||
if "url" in resp_json:
|
||||
return resp_json["url"]
|
||||
else:
|
||||
logger.error(f"Ответ WATA без url: {resp_json}")
|
||||
return "https://wata.pro/"
|
||||
else:
|
||||
|
||||
try:
|
||||
error_json = await resp.json()
|
||||
logger.error(f"Ошибка WATA API: статус={resp.status}, ответ={error_json}")
|
||||
except Exception:
|
||||
text = await resp.text()
|
||||
logger.error(f"Ошибка WATA API: статус={resp.status}, не-JSON ответ: {text}")
|
||||
logger.error(f"Ответ WATA без url: {resp_json}")
|
||||
return "https://wata.pro/"
|
||||
|
||||
try:
|
||||
error_json = await resp.json()
|
||||
logger.error(f"Ошибка WATA API: статус={resp.status}, ответ={error_json}")
|
||||
except Exception:
|
||||
text = await resp.text()
|
||||
logger.error(f"Ошибка WATA API: статус={resp.status}, не-JSON ответ: {text}")
|
||||
return "https://wata.pro/"
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
-9
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -23,10 +24,11 @@ from handlers.buttons import (
|
||||
GIFTS,
|
||||
INSTRUCTIONS,
|
||||
INVITE,
|
||||
MY_SUB,
|
||||
MY_SUBS,
|
||||
RENEW_KEY,
|
||||
TRIAL_SUB,
|
||||
)
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.texts import ADD_SUBSCRIPTION_HINT
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
@@ -59,22 +61,40 @@ async def process_callback_view_profile(
|
||||
chat_id = chat.id
|
||||
username = get_username(user or chat)
|
||||
|
||||
key_count = await get_key_count(session, chat_id)
|
||||
balance = await get_balance(session, chat_id) or 0
|
||||
trial_status = await get_trial(session, chat_id)
|
||||
key_count, balance_rub, trial_status = await asyncio.gather(
|
||||
get_key_count(session, chat_id),
|
||||
get_balance(session, chat_id),
|
||||
get_trial(session, chat_id),
|
||||
)
|
||||
balance_rub = balance_rub or 0
|
||||
|
||||
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
|
||||
fmt_task = asyncio.create_task(format_for_user(session, chat_id, balance_rub, getattr(user, "language_code", None)))
|
||||
profile_menu_task = asyncio.create_task(run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session))
|
||||
profile_text_task = asyncio.create_task(
|
||||
run_hooks(
|
||||
"profile_text",
|
||||
username=username,
|
||||
chat_id=chat_id,
|
||||
balance=int(balance_rub),
|
||||
key_count=key_count,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
|
||||
balance_text = await fmt_task
|
||||
|
||||
profile_message = profile_message_send(username, chat_id, balance_text, key_count)
|
||||
profile_message += ADD_SUBSCRIPTION_HINT if key_count == 0 else f"\n<blockquote><i>{NEWS_MESSAGE}</i></blockquote>"
|
||||
|
||||
text_hooks = await run_hooks("profile_text", username=username, chat_id=chat_id, balance=int(balance), key_count=key_count, session=session)
|
||||
text_hooks = await profile_text_task
|
||||
if text_hooks:
|
||||
profile_message = text_hooks[0]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if key_count > 0:
|
||||
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data="renew_menu"))
|
||||
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
|
||||
subs_label = MY_SUB if key_count == 1 else MY_SUBS
|
||||
builder.row(InlineKeyboardButton(text=subs_label, callback_data="view_keys"))
|
||||
elif trial_status == 0 and not TRIAL_TIME_DISABLE:
|
||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||
else:
|
||||
@@ -91,7 +111,7 @@ async def process_callback_view_profile(
|
||||
if extra_buttons:
|
||||
builder.row(*extra_buttons)
|
||||
|
||||
module_buttons = await run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session)
|
||||
module_buttons = await profile_menu_task
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
|
||||
@@ -31,6 +31,7 @@ from database import (
|
||||
from database.models import Referral
|
||||
from database.tariffs import get_tariffs
|
||||
from handlers.buttons import BACK, INVITE, MAIN_MENU, QR, TOP_FIVE
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.texts import (
|
||||
INVITE_MESSAGE_TEMPLATE,
|
||||
INVITE_TEXT_NON_INLINE,
|
||||
@@ -54,9 +55,11 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery, ses
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
target_message = callback_query_or_message.message
|
||||
language_code = callback_query_or_message.from_user.language_code
|
||||
else:
|
||||
chat_id = callback_query_or_message.chat.id
|
||||
target_message = callback_query_or_message
|
||||
language_code = callback_query_or_message.from_user.language_code
|
||||
|
||||
referral_link = get_referral_link(chat_id)
|
||||
referral_stats = await get_referral_stats(session, chat_id)
|
||||
@@ -66,7 +69,8 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery, ses
|
||||
if isinstance(value, float):
|
||||
bonuses_lines.append(f"{level} уровень: 🌟 {int(value * 100)}% бонуса")
|
||||
else:
|
||||
bonuses_lines.append(f"{level} уровень: 💸 {int(value)}₽ бонуса")
|
||||
value_txt = await format_for_user(session, chat_id, value, language_code)
|
||||
bonuses_lines.append(f"{level} уровень: 💸 {value_txt} бонуса")
|
||||
bonuses_block = "\n".join(bonuses_lines)
|
||||
|
||||
details_lines = []
|
||||
@@ -75,16 +79,18 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery, ses
|
||||
if isinstance(bonus_value, float):
|
||||
bonus_str = f"{int(bonus_value * 100)}%"
|
||||
else:
|
||||
bonus_str = f"{int(bonus_value)}₽"
|
||||
bonus_str = await format_for_user(session, chat_id, bonus_value, language_code)
|
||||
details_lines.append(f"🔹 Уровень {level}: {stats['total']} - {bonus_str}")
|
||||
details_block = "\n".join(details_lines)
|
||||
|
||||
total_bonus_txt = await format_for_user(session, chat_id, referral_stats["total_referral_bonus"], language_code)
|
||||
|
||||
invite_message = INVITE_MESSAGE_TEMPLATE.format(
|
||||
referral_link=referral_link,
|
||||
bonuses_block=bonuses_block,
|
||||
total_referrals=referral_stats["total_referrals"],
|
||||
details_block=details_block,
|
||||
total_referral_bonus=referral_stats["total_referral_bonus"],
|
||||
total_referral_bonus=total_bonus_txt,
|
||||
)
|
||||
image_path = os.path.join("img", "pic_invite.jpg")
|
||||
|
||||
|
||||
+17
-18
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
@@ -43,7 +44,6 @@ from handlers.captcha import generate_captcha
|
||||
from handlers.coupons import activate_coupon
|
||||
from handlers.payments.gift import handle_gift_link
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from handlers.texts import (
|
||||
NOT_SUBSCRIBED_YET_MSG,
|
||||
SUBSCRIPTION_CHECK_ERROR_MSG,
|
||||
@@ -52,6 +52,7 @@ from handlers.texts import (
|
||||
WELCOME_TEXT,
|
||||
get_about_vpn,
|
||||
)
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
|
||||
@@ -115,6 +116,8 @@ async def process_start_logic(
|
||||
if text.startswith("/start "):
|
||||
text = text.split(maxsplit=1)[1]
|
||||
|
||||
await state.update_data(original_text=text, user_data=user_data)
|
||||
|
||||
gift_detected = False
|
||||
for part in text.split("-"):
|
||||
await run_hooks("start_link", message=message, state=state, session=session, user_data=user_data, part=part)
|
||||
@@ -138,16 +141,18 @@ async def process_start_logic(
|
||||
if not await check_user_exists(session, user_data["tg_id"]):
|
||||
await add_user(session=session, **user_data)
|
||||
|
||||
trial_status = await get_trial(session, user_data["tg_id"])
|
||||
key_count = await get_key_count(session, user_data["tg_id"])
|
||||
trial_status, key_count = await asyncio.gather(
|
||||
get_trial(session, user_data["tg_id"]),
|
||||
get_key_count(session, user_data["tg_id"]),
|
||||
)
|
||||
|
||||
if SHOW_START_MENU_ONCE:
|
||||
if key_count > 0 or trial_status != 0:
|
||||
await process_callback_view_profile(message, state, admin, session)
|
||||
else:
|
||||
await show_start_menu(message, admin, session)
|
||||
await show_start_menu(message, admin, session, trial_status=trial_status)
|
||||
else:
|
||||
await show_start_menu(message, admin, session)
|
||||
await show_start_menu(message, admin, session, trial_status=trial_status)
|
||||
|
||||
|
||||
async def handle_coupon_link(part, message, state, session, admin, user_data):
|
||||
@@ -173,12 +178,8 @@ async def handle_gift(part, message, state, session, user_data):
|
||||
|
||||
processing_gifts.add(gift_id)
|
||||
try:
|
||||
gift_results = await run_hooks("gift_activation",
|
||||
gift_id=gift_id,
|
||||
message=message,
|
||||
state=state,
|
||||
session=session,
|
||||
user_data=user_data
|
||||
gift_results = await run_hooks(
|
||||
"gift_activation", gift_id=gift_id, message=message, state=state, session=session, user_data=user_data
|
||||
)
|
||||
|
||||
if gift_results and "SUCCESS" in gift_results:
|
||||
@@ -232,11 +233,13 @@ async def handle_utm_link(utm_code: str, message: Message, state: FSMContext, se
|
||||
await add_user(session=session, source_code=utm_code, **user_data)
|
||||
|
||||
|
||||
async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
|
||||
async def show_start_menu(message: Message, admin: bool, session: AsyncSession, trial_status: int | None = None):
|
||||
image_path = os.path.join("img", "pic.jpg")
|
||||
kb = InlineKeyboardBuilder()
|
||||
|
||||
trial_status = await get_trial(session, message.chat.id) if session else None
|
||||
if trial_status is None:
|
||||
trial_status = await get_trial(session, message.chat.id) if session else None
|
||||
|
||||
show_trial = trial_status == 0 and not TRIAL_TIME_DISABLE
|
||||
show_profile = not SHOW_START_MENU_ONCE or trial_status != 0 or TRIAL_TIME_DISABLE
|
||||
|
||||
@@ -292,9 +295,5 @@ async def handle_about_vpn(callback: CallbackQuery, session: AsyncSession):
|
||||
text = text_hooks[0]
|
||||
|
||||
await edit_or_send_message(
|
||||
callback.message,
|
||||
text,
|
||||
reply_markup=kb.as_markup(),
|
||||
media_path=os.path.join("img", "pic.jpg"),
|
||||
force_text=False
|
||||
callback.message, text, reply_markup=kb.as_markup(), media_path=os.path.join("img", "pic.jpg"), force_text=False
|
||||
)
|
||||
|
||||
+3
-3
@@ -309,15 +309,15 @@ def format_discount_time_left(last_time: datetime, discount_hours: int) -> str:
|
||||
expires_at = last_time + timedelta(hours=discount_hours)
|
||||
current_time = datetime.utcnow()
|
||||
time_left = expires_at - current_time
|
||||
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
return "⏳ Время истекло"
|
||||
|
||||
|
||||
total_seconds = int(time_left.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
|
||||
if days > 0:
|
||||
return format_days(days)
|
||||
elif hours > 0:
|
||||
|
||||
@@ -19,7 +19,7 @@ def insert_hook_buttons(builder: InlineKeyboardBuilder, buttons: list) -> Inline
|
||||
buttons = buttons or []
|
||||
flat_buttons = []
|
||||
for item in buttons:
|
||||
if isinstance(item, (list, tuple)):
|
||||
if isinstance(item, list | tuple):
|
||||
flat_buttons.extend(item)
|
||||
else:
|
||||
flat_buttons.append(item)
|
||||
@@ -48,7 +48,7 @@ def insert_hook_buttons(builder: InlineKeyboardBuilder, buttons: list) -> Inline
|
||||
for module in insert_operations:
|
||||
insert_at = module["insert_at"]
|
||||
button = module["button"]
|
||||
|
||||
|
||||
if 0 <= insert_at <= len(new_rows):
|
||||
new_rows.insert(insert_at, [button])
|
||||
else:
|
||||
|
||||
@@ -54,3 +54,7 @@ logger.add(
|
||||
)
|
||||
|
||||
logger = logger
|
||||
|
||||
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
||||
logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING)
|
||||
logging.getLogger("apscheduler.scheduler").setLevel(logging.WARNING)
|
||||
|
||||
@@ -8,13 +8,13 @@ from middlewares.ban_checker import BanCheckerMiddleware
|
||||
from middlewares.subscription import SubscriptionMiddleware
|
||||
|
||||
from .admin import AdminMiddleware
|
||||
from .answer import CallbackAnswerMiddleware
|
||||
from .direct_start_blocker import DirectStartBlockerMiddleware
|
||||
from .loggings import LoggingMiddleware
|
||||
from .maintenance import MaintenanceModeMiddleware
|
||||
from .session import SessionMiddleware
|
||||
from .throttling import ThrottlingMiddleware
|
||||
from .user import UserMiddleware
|
||||
from .answer import CallbackAnswerMiddleware
|
||||
|
||||
|
||||
def register_middleware(
|
||||
|
||||
+70
-46
@@ -14,6 +14,8 @@ from logger import logger
|
||||
|
||||
|
||||
TZ = timezone("Europe/Moscow")
|
||||
_BAN_CACHE_TTL = 30
|
||||
_ban_cache: dict[int, tuple[float, dict | None]] = {}
|
||||
|
||||
|
||||
class BanCheckerMiddleware(BaseMiddleware):
|
||||
@@ -43,53 +45,75 @@ class BanCheckerMiddleware(BaseMiddleware):
|
||||
if tg_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
async with self.session_factory() as session:
|
||||
logger.debug(f"[BanChecker] Проверка блокировки для пользователя {tg_id}")
|
||||
result = await session.execute(
|
||||
select(ManualBan).where(
|
||||
ManualBan.tg_id == tg_id,
|
||||
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()),
|
||||
)
|
||||
now_ts = datetime.utcnow().timestamp()
|
||||
cached = _ban_cache.get(tg_id)
|
||||
if cached and cached[0] > now_ts:
|
||||
ban_info = cached[1]
|
||||
else:
|
||||
session: AsyncSession | None = (
|
||||
data.get("session") if isinstance(data.get("session"), AsyncSession) else None
|
||||
)
|
||||
ban = result.scalar_one_or_none()
|
||||
|
||||
if ban:
|
||||
reason = ban.reason or "не указана"
|
||||
until = ban.until
|
||||
|
||||
if reason == "shadow":
|
||||
logger.info(f"[BanChecker] Теневой бан: пользователь {tg_id} — действия игнорируются.")
|
||||
return
|
||||
|
||||
logger.warning(f"[BanChecker] Пользователь {tg_id} заблокирован (до: {until}, причина: {reason})")
|
||||
|
||||
if until:
|
||||
until_local = until.astimezone(TZ).strftime("%Y-%m-%d %H:%M")
|
||||
text_html = (
|
||||
f"🚫 Вы заблокированы до <b>{until_local}</b> по МСК.\n"
|
||||
f"📄 Причина: <i>{reason}</i>\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
text_plain = (
|
||||
f"🚫 Вы заблокированы до {until_local} по МСК.\n"
|
||||
f"📄 Причина: {reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
created_here = False
|
||||
if session is None:
|
||||
session = self.session_factory()
|
||||
created_here = True
|
||||
try:
|
||||
q = (
|
||||
select(ManualBan.reason, ManualBan.until)
|
||||
.where(
|
||||
ManualBan.tg_id == tg_id,
|
||||
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
res = await session.execute(q)
|
||||
row = res.first()
|
||||
if row:
|
||||
reason, until = row
|
||||
ban_info = {"reason": reason or "не указана", "until": until}
|
||||
else:
|
||||
text_html = (
|
||||
f"🚫 Вы заблокированы <b>навсегда</b>.\n"
|
||||
f"📄 Причина: <i>{reason}</i>\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
text_plain = (
|
||||
f"🚫 Вы заблокированы навсегда.\n"
|
||||
f"📄 Причина: {reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
ban_info = None
|
||||
finally:
|
||||
if created_here:
|
||||
await session.close()
|
||||
_ban_cache[tg_id] = (now_ts + _BAN_CACHE_TTL, ban_info)
|
||||
|
||||
if isinstance(obj, Message):
|
||||
await obj.answer(text_html, parse_mode="HTML")
|
||||
elif isinstance(obj, CallbackQuery):
|
||||
await obj.answer(text_plain, show_alert=True)
|
||||
return
|
||||
return await handler(event, data)
|
||||
if not ban_info:
|
||||
return await handler(event, data)
|
||||
|
||||
reason = ban_info["reason"]
|
||||
until = ban_info["until"]
|
||||
|
||||
if reason == "shadow":
|
||||
logger.info(f"[BanChecker] Теневой бан: пользователь {tg_id} — действия игнорируются.")
|
||||
return
|
||||
|
||||
if until:
|
||||
until_local = until.astimezone(TZ).strftime("%Y-%m-%d %H:%M")
|
||||
text_html = (
|
||||
f"🚫 Вы заблокированы до <b>{until_local}</b> по МСК.\n"
|
||||
f"📄 Причина: <i>{reason}</i>\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
text_plain = (
|
||||
f"🚫 Вы заблокированы до {until_local} по МСК.\n"
|
||||
f"📄 Причина: {reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
else:
|
||||
text_html = (
|
||||
f"🚫 Вы заблокированы <b>навсегда</b>.\n"
|
||||
f"📄 Причина: <i>{reason}</i>\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
text_plain = (
|
||||
f"🚫 Вы заблокированы навсегда.\n"
|
||||
f"📄 Причина: {reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
|
||||
if isinstance(obj, Message):
|
||||
await obj.answer(text_html, parse_mode="HTML")
|
||||
elif isinstance(obj, CallbackQuery):
|
||||
await obj.answer(text_plain, show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import time
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, Update
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
|
||||
from config import DISABLE_DIRECT_START
|
||||
from database import async_session_maker, check_user_exists
|
||||
@@ -12,6 +14,14 @@ from database.models import Coupon, Gift, TrackingSource, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
_TTL = 20
|
||||
_cache_user_exists: dict[int, tuple[float, bool]] = {}
|
||||
_cache_coupon: dict[str, tuple[float, bool]] = {}
|
||||
_cache_gift: dict[str, tuple[float, bool]] = {}
|
||||
_cache_ref: dict[int, tuple[float, bool]] = {}
|
||||
_cache_utm: dict[str, tuple[float, bool]] = {}
|
||||
|
||||
|
||||
class DirectStartBlockerMiddleware(BaseMiddleware):
|
||||
def __init__(self) -> None:
|
||||
self.allowed_prefixes = ("gift_", "referral_", "coupons_", "utm", "partner_")
|
||||
@@ -22,8 +32,6 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
logger.debug("[DirectStartBlocker] Middleware triggered")
|
||||
|
||||
if not DISABLE_DIRECT_START:
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -31,72 +39,120 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
|
||||
if not message or not message.text:
|
||||
return await handler(event, data)
|
||||
|
||||
fsm = data.get("state")
|
||||
if fsm:
|
||||
current_state = await fsm.get_state()
|
||||
if current_state:
|
||||
return await handler(event, data)
|
||||
|
||||
tg_id = message.from_user.id
|
||||
text = message.text.strip()
|
||||
now = time.time()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user_exists = await check_user_exists(session, tg_id)
|
||||
async def user_exists_cached() -> bool:
|
||||
cached = _cache_user_exists.get(tg_id)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
async with async_session_maker() as session:
|
||||
exists = await check_user_exists(session, tg_id)
|
||||
_cache_user_exists[tg_id] = (now + _TTL, exists)
|
||||
return exists
|
||||
|
||||
if user_exists:
|
||||
logger.debug(f"[DirectStartBlocker] Пользователь {tg_id} уже есть в базе — пропущен")
|
||||
if not text.startswith("/"):
|
||||
return await handler(event, data)
|
||||
|
||||
parts = text.split(maxsplit=1)
|
||||
if parts[0] != "/start":
|
||||
if await user_exists_cached():
|
||||
return await handler(event, data)
|
||||
|
||||
parts = text.split(maxsplit=1)
|
||||
if parts[0] == "/start":
|
||||
if len(parts) == 1:
|
||||
logger.info(f"[DirectStartBlocker] Прямой старт запрещён для нового пользователя {tg_id}")
|
||||
return
|
||||
|
||||
start_param = parts[1].strip()
|
||||
if not start_param or not start_param.startswith(self.allowed_prefixes):
|
||||
logger.info(f"[DirectStartBlocker] Отклонена неизвестная ссылка от {tg_id}: {start_param!r}")
|
||||
return
|
||||
|
||||
if start_param.startswith("coupons_"):
|
||||
code = start_param.removeprefix("coupons_")
|
||||
result = await session.execute(select(Coupon).where(Coupon.code == code))
|
||||
if not result.scalar_one_or_none():
|
||||
logger.info(f"[DirectStartBlocker] Купон не найден: {code!r}")
|
||||
return
|
||||
|
||||
elif start_param.startswith("gift_"):
|
||||
gift_id = start_param.removeprefix("gift_")
|
||||
result = await session.execute(
|
||||
select(Gift).where(
|
||||
Gift.gift_id == gift_id,
|
||||
Gift.is_used.is_(False),
|
||||
(Gift.expiry_time.is_(None)) | (Gift.expiry_time > datetime.utcnow()),
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
logger.info(f"[DirectStartBlocker] Подарок неактивен или не найден: {gift_id!r}")
|
||||
return
|
||||
|
||||
elif start_param.startswith("referral_"):
|
||||
try:
|
||||
ref_id = int(start_param.removeprefix("referral_"))
|
||||
result = await session.execute(select(User).where(User.tg_id == ref_id))
|
||||
if not result.scalar_one_or_none():
|
||||
logger.info(f"[DirectStartBlocker] Реферал не найден: {ref_id!r}")
|
||||
return
|
||||
except ValueError:
|
||||
logger.info(f"[DirectStartBlocker] Неверный формат referral-ссылки: {start_param!r}")
|
||||
return
|
||||
|
||||
elif start_param.startswith("utm"):
|
||||
utm_code = start_param
|
||||
result = await session.execute(select(TrackingSource).where(TrackingSource.code == utm_code))
|
||||
if not result.scalar_one_or_none():
|
||||
logger.info(f"[DirectStartBlocker] UTM не найден: {utm_code!r}")
|
||||
return
|
||||
|
||||
logger.info(f"[DirectStartBlocker] Разрешённая и валидная ссылка от {tg_id}: {start_param!r}")
|
||||
return await handler(event, data)
|
||||
|
||||
if text.startswith("/"):
|
||||
logger.info(
|
||||
f"[DirectStartBlocker] Команда '{text}' отклонена для незарегистрированного пользователя {tg_id}"
|
||||
)
|
||||
return
|
||||
|
||||
return
|
||||
if len(parts) == 1:
|
||||
if await user_exists_cached():
|
||||
return await handler(event, data)
|
||||
logger.info(f"[DirectStartBlocker] Прямой старт запрещён для нового пользователя {tg_id}")
|
||||
return
|
||||
|
||||
start_param = parts[1].strip()
|
||||
if not start_param or not start_param.startswith(self.allowed_prefixes):
|
||||
if await user_exists_cached():
|
||||
return await handler(event, data)
|
||||
logger.info(f"[DirectStartBlocker] Отклонена неизвестная ссылка от {tg_id}: {start_param!r}")
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
if start_param.startswith("coupons_"):
|
||||
code = start_param.removeprefix("coupons_")
|
||||
cached = _cache_coupon.get(code)
|
||||
if cached and cached[0] > now:
|
||||
ok = cached[1]
|
||||
else:
|
||||
ok = (
|
||||
await session.execute(select(Coupon.id).where(Coupon.code == code).limit(1))
|
||||
).first() is not None
|
||||
_cache_coupon[code] = (now + _TTL, ok)
|
||||
if not ok:
|
||||
logger.info(f"[DirectStartBlocker] Купон не найден: {code!r}")
|
||||
return
|
||||
|
||||
elif start_param.startswith("gift_"):
|
||||
gift_id = start_param.removeprefix("gift_")
|
||||
cached = _cache_gift.get(gift_id)
|
||||
if cached and cached[0] > now:
|
||||
ok = cached[1]
|
||||
else:
|
||||
ok = (
|
||||
await session.execute(
|
||||
select(Gift.id)
|
||||
.where(
|
||||
Gift.gift_id == gift_id,
|
||||
Gift.is_used.is_(False),
|
||||
(Gift.expiry_time.is_(None)) | (Gift.expiry_time > datetime.utcnow()),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).first() is not None
|
||||
_cache_gift[gift_id] = (now + _TTL, ok)
|
||||
if not ok:
|
||||
logger.info(f"[DirectStartBlocker] Подарок неактивен или не найден: {gift_id!r}")
|
||||
return
|
||||
|
||||
elif start_param.startswith("referral_"):
|
||||
try:
|
||||
ref_id = int(start_param.removeprefix("referral_"))
|
||||
except ValueError:
|
||||
logger.info(f"[DirectStartBlocker] Неверный формат referral-ссылки: {start_param!r}")
|
||||
return
|
||||
cached = _cache_ref.get(ref_id)
|
||||
if cached and cached[0] > now:
|
||||
ok = cached[1]
|
||||
else:
|
||||
ok = (
|
||||
await session.execute(select(User.tg_id).where(User.tg_id == ref_id).limit(1))
|
||||
).first() is not None
|
||||
_cache_ref[ref_id] = (now + _TTL, ok)
|
||||
if not ok:
|
||||
logger.info(f"[DirectStartBlocker] Реферал не найден: {ref_id!r}")
|
||||
return
|
||||
|
||||
elif start_param.startswith("utm"):
|
||||
utm_code = start_param
|
||||
cached = _cache_utm.get(utm_code)
|
||||
if cached and cached[0] > now:
|
||||
ok = cached[1]
|
||||
else:
|
||||
ok = (
|
||||
await session.execute(
|
||||
select(TrackingSource.code).where(TrackingSource.code == utm_code).limit(1)
|
||||
)
|
||||
).first() is not None
|
||||
_cache_utm[utm_code] = (now + _TTL, ok)
|
||||
if not ok:
|
||||
logger.info(f"[DirectStartBlocker] UTM не найден: {utm_code!r}")
|
||||
return
|
||||
|
||||
logger.info(f"[DirectStartBlocker] Разрешённая и валидная ссылка от {tg_id}: {start_param!r}")
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
from aiogram import BaseMiddleware
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
|
||||
class SessionMiddleware(BaseMiddleware):
|
||||
def __init__(self, sessionmaker=None) -> None:
|
||||
def __init__(self, sessionmaker) -> None:
|
||||
super().__init__()
|
||||
if sessionmaker is None:
|
||||
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=0)
|
||||
self.sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
else:
|
||||
self.sessionmaker = sessionmaker
|
||||
self.sessionmaker = sessionmaker
|
||||
|
||||
async def __call__(self, handler, event, data):
|
||||
if data.get("session"):
|
||||
return await handler(event, data)
|
||||
async with self.sessionmaker() as session:
|
||||
data["session"] = session
|
||||
return await handler(event, data)
|
||||
|
||||
+31
-20
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
@@ -9,10 +10,9 @@ from logger import logger
|
||||
|
||||
|
||||
class UserMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для обработки информации о пользователе.
|
||||
Сохраняет или обновляет данные пользователя в базе данных.
|
||||
"""
|
||||
def __init__(self, debounce_sec: float = 60.0) -> None:
|
||||
self._debounce = float(debounce_sec)
|
||||
self._cache: dict[int, tuple[str, float, dict | None]] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -21,30 +21,30 @@ class UserMiddleware(BaseMiddleware):
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
try:
|
||||
if user := data.get("event_from_user"):
|
||||
user: User | None = data.get("event_from_user")
|
||||
if user and not user.is_bot:
|
||||
session = data.get("session")
|
||||
db_user = await self._process_user(user, session)
|
||||
if db_user:
|
||||
data["user"] = db_user
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке пользователя: {e}")
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
async def _process_user(self, user: User, session: Any = None) -> dict:
|
||||
"""
|
||||
Обрабатывает информацию о пользователе и сохраняет её в базу данных.
|
||||
async def _process_user(self, user: User, session: Any = None) -> dict | None:
|
||||
uid = user.id
|
||||
fp = self._fingerprint(user)
|
||||
now = monotonic()
|
||||
|
||||
Args:
|
||||
user (User): Объект пользователя Telegram
|
||||
session (Any, optional): Сессия базы данных, если доступна
|
||||
cached = self._cache.get(uid)
|
||||
if cached:
|
||||
cached_fp, ts, cached_db_user = cached
|
||||
if fp == cached_fp and now - ts < self._debounce:
|
||||
return cached_db_user
|
||||
|
||||
Returns:
|
||||
dict: Словарь с информацией о пользователе из базы данных
|
||||
"""
|
||||
logger.debug(f"Обработка пользователя: {user.id}")
|
||||
user_data = await upsert_user(
|
||||
tg_id=user.id,
|
||||
logger.debug(f"Обработка пользователя: {uid}")
|
||||
db_user = await upsert_user(
|
||||
tg_id=uid,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
@@ -53,6 +53,17 @@ class UserMiddleware(BaseMiddleware):
|
||||
session=session,
|
||||
only_if_exists=True,
|
||||
)
|
||||
self._cache[uid] = (fp, now, db_user)
|
||||
if db_user:
|
||||
logger.debug(f"Получены данные пользователя из БД: {uid}")
|
||||
return db_user
|
||||
|
||||
logger.debug(f"Получены данные пользователя из БД: {user.id}")
|
||||
return user_data
|
||||
def _fingerprint(self, user: User) -> str:
|
||||
return "|".join([
|
||||
str(user.id),
|
||||
user.username or "",
|
||||
user.first_name or "",
|
||||
user.last_name or "",
|
||||
user.language_code or "",
|
||||
"1" if user.is_bot else "0",
|
||||
])
|
||||
|
||||
Binary file not shown.
+10
-5
@@ -47,10 +47,10 @@ def load_module_webhooks(folder: str = "modules") -> list[dict]:
|
||||
if isinstance(webhook_data, dict) and "path" in webhook_data and "handler" in webhook_data:
|
||||
webhooks.append(webhook_data)
|
||||
logger.info(f"[Modules] Найден вебхук в модуле {name}: {webhook_data['path']}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Modules] Ошибка при загрузке вебхуков из {module_path}: {e}")
|
||||
|
||||
|
||||
return webhooks
|
||||
|
||||
|
||||
@@ -68,15 +68,20 @@ def load_module_fast_flow_handlers(folder: str = "modules") -> dict:
|
||||
router_module = importlib.import_module(f"{module_path}.router")
|
||||
if hasattr(router_module, "get_fast_flow_handler"):
|
||||
fast_flow_data = router_module.get_fast_flow_handler()
|
||||
if fast_flow_data and isinstance(fast_flow_data, dict) and "payment_key" in fast_flow_data and "handler" in fast_flow_data:
|
||||
if (
|
||||
fast_flow_data
|
||||
and isinstance(fast_flow_data, dict)
|
||||
and "payment_key" in fast_flow_data
|
||||
and "handler" in fast_flow_data
|
||||
):
|
||||
payment_key = fast_flow_data["payment_key"]
|
||||
handler = fast_flow_data["handler"]
|
||||
handlers[payment_key] = handler
|
||||
logger.info(f"[Modules] Найден обработчик быстрого флоу в модуле {name}: {payment_key}")
|
||||
elif fast_flow_data is None:
|
||||
logger.info(f"[Modules] Быстрое флоу отключено в модуле {name}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Modules] Ошибка при загрузке быстрого флоу из {module_path}: {e}")
|
||||
|
||||
|
||||
return handlers
|
||||
|
||||
+1
-1
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return f"v.5 - preview {get_git_commit_number()}"
|
||||
return f"v.5-150949 {get_git_commit_number()}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user