currency/fast flow for everyone/optimization and cache/new payment system/and more

This commit is contained in:
Vladless
2025-09-15 21:55:31 +03:00
parent 6d9e1aba4a
commit d6b920ff4d
102 changed files with 3033 additions and 1710 deletions
+6 -3
View File
@@ -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
View File
@@ -1,4 +1,5 @@
from datetime import datetime
from pydantic import BaseModel
+1 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -9
View File
@@ -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)
)
-2
View File
@@ -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,
+15 -18
View File
@@ -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 -10
View File
@@ -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))
+2 -1
View File
@@ -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()
+31 -15
View File
@@ -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(),
)
],
]
+43 -26
View File
@@ -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
+21 -38
View File
@@ -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
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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"))
+5 -3
View File
@@ -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}")
+4 -2
View File
@@ -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:
+5 -3
View File
@@ -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:
+57 -64
View File
@@ -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()
+11 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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):
+52 -17
View File
@@ -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)
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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]:
+1 -1
View File
@@ -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
+25 -36
View File
@@ -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
View File
@@ -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)
+29 -31
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+76
View File
@@ -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
+187
View File
@@ -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
+217
View File
@@ -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"))
@@ -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(),
)
@@ -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
+96
View File
@@ -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()
+202
View File
@@ -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)
+93
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+162
View File
@@ -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=[]),
)
+87
View File
@@ -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
+53
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
View File
@@ -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/"
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+29 -9
View File
@@ -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:
+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+2 -2
View File
@@ -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:
+4
View File
@@ -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)
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
View File
@@ -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
+118 -62
View File
@@ -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)
+4 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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