Docker/ API 0.5.0: Partner Statistics/Coupons for New Customers and More
This commit is contained in:
+3
-3
@@ -43,7 +43,6 @@ database.db
|
||||
bot_old.py
|
||||
bot_old_2.py
|
||||
backup_pg.sh
|
||||
docker-compose.yml
|
||||
config copy.py
|
||||
handlers/texts.py
|
||||
|
||||
@@ -53,11 +52,12 @@ Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
Dockerfile
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
storage/
|
||||
|
||||
.license_state
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /app/venv \
|
||||
&& python -m venv /app/venv \
|
||||
&& /app/venv/bin/pip install --upgrade pip \
|
||||
&& /app/venv/bin/pip install -r requirements.txt
|
||||
|
||||
CMD ["/app/venv/bin/python", "main.py"]
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
from fastapi import FastAPI
|
||||
from api.routes import coupons, gifts, keys, misc, referrals, servers, settings, tariffs, users
|
||||
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc, partners
|
||||
|
||||
app = FastAPI(
|
||||
title="SoloBot API (Alpha)",
|
||||
version="0.4.0",
|
||||
version="0.5.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
@@ -16,7 +16,7 @@ app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
|
||||
|
||||
|
||||
+34
-15
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,13 +9,23 @@ from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
@@ -35,7 +47,10 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
return result.scalars().all()
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
return [schema_response.model_validate(item, from_attributes=True) for item in items]
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@@ -49,7 +64,7 @@ def generate_crud_router(
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@@ -60,12 +75,12 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_all_by_field" in enabled_methods:
|
||||
|
||||
@@ -76,52 +91,56 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return objs
|
||||
for obj in objs:
|
||||
normalize_outgoing_object(obj)
|
||||
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: schema_create, # type: ignore
|
||||
payload: Any,
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = payload.dict(exclude_unset=True)
|
||||
validated = schema_create.model_validate(payload)
|
||||
data = validated.model_dump(exclude_unset=True)
|
||||
if "days" in data and data["days"] == 0:
|
||||
data["days"] = None
|
||||
obj = model(**data)
|
||||
session.add(obj)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: schema_update, # type: ignore
|
||||
payload: Any,
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
for k, v in payload.dict(exclude_unset=True).items():
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@@ -132,7 +151,7 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
+4
-20
@@ -93,31 +93,15 @@ async def edit_key_by_email(
|
||||
setattr(db_key, field, value)
|
||||
|
||||
try:
|
||||
tariff = None
|
||||
if db_key.tariff_id is not None:
|
||||
tariff_result = await session.execute(select(Tariff).where(Tariff.id == db_key.tariff_id))
|
||||
tariff = tariff_result.scalar_one_or_none()
|
||||
|
||||
total_gb = db_key.current_traffic_limit
|
||||
if total_gb is None:
|
||||
total_gb = db_key.selected_traffic_limit
|
||||
if total_gb is None and tariff is not None:
|
||||
total_gb = tariff.traffic_limit
|
||||
|
||||
hwid_device_limit = db_key.current_device_limit
|
||||
if hwid_device_limit is None:
|
||||
hwid_device_limit = db_key.selected_device_limit
|
||||
if hwid_device_limit is None and tariff is not None:
|
||||
hwid_device_limit = tariff.device_limit
|
||||
|
||||
new_expiry_time = db_key.expiry_time
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=db_key.server_id,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
new_expiry_time=db_key.expiry_time,
|
||||
total_gb=total_gb,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=getattr(db_key, "traffic_limit", None),
|
||||
session=session,
|
||||
hwid_device_limit=hwid_device_limit,
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_partners(
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список всех партнёров со статистикой.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total": int,
|
||||
"items": [
|
||||
{
|
||||
"tg_id": int,
|
||||
"balance": float,
|
||||
"percent": float,
|
||||
"code": str | None,
|
||||
"method": str | None,
|
||||
"referred_count": int
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
partners_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
p.partner_tg_id AS tg_id,
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
COALESCE(u.partner_percent, 0) AS partner_percent,
|
||||
u.partner_code,
|
||||
u.payout_method,
|
||||
COUNT(p.joined_tg_id) as joined_count
|
||||
FROM partners p
|
||||
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
|
||||
WHERE p.partner_tg_id IS NOT NULL
|
||||
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_code, u.payout_method
|
||||
ORDER BY partner_balance DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
count_sql = text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
|
||||
partners = result.fetchall()
|
||||
|
||||
count_result = await session.execute(count_sql)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
partners_list = [
|
||||
{
|
||||
"tg_id": int(partner[0]),
|
||||
"balance": float(partner[1] or 0),
|
||||
"percent": float(partner[2] or 0),
|
||||
"code": partner[3] or None,
|
||||
"method": partner[4] or None,
|
||||
"referred_count": int(partner[5] or 0),
|
||||
}
|
||||
for partner in partners
|
||||
]
|
||||
|
||||
return JSONResponse(content={"total": total, "items": partners_list})
|
||||
|
||||
|
||||
@router.get("/stats/all")
|
||||
async def get_partners_stats(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает общую статистику партнёрской программы.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total_partners": int,
|
||||
"total_referred": int,
|
||||
"total_balance": float,
|
||||
"top_partner_tg_id": int,
|
||||
"top_partner_refs": int
|
||||
}
|
||||
"""
|
||||
|
||||
stats_sql = text(
|
||||
"""
|
||||
WITH partner_refs AS (
|
||||
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
GROUP BY partner_tg_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
|
||||
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
|
||||
(
|
||||
SELECT COALESCE(SUM(u.partner_balance), 0.0)
|
||||
FROM users u
|
||||
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
|
||||
) AS total_balance,
|
||||
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
|
||||
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
|
||||
"""
|
||||
)
|
||||
|
||||
stats_result = await session.execute(stats_sql)
|
||||
stats_row = stats_result.fetchone()
|
||||
|
||||
if stats_row:
|
||||
stats = {
|
||||
"total_partners": int(stats_row[0] or 0),
|
||||
"total_referred": int(stats_row[1] or 0),
|
||||
"total_balance": float(stats_row[2] or 0.0),
|
||||
"top_partner_tg_id": int(stats_row[3] or 0),
|
||||
"top_partner_refs": int(stats_row[4] or 0),
|
||||
}
|
||||
else:
|
||||
stats = {
|
||||
"total_partners": 0,
|
||||
"total_referred": 0,
|
||||
"total_balance": 0.0,
|
||||
"top_partner_tg_id": 0,
|
||||
"top_partner_refs": 0,
|
||||
}
|
||||
|
||||
return JSONResponse(content=stats)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}")
|
||||
async def update_partner(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
balance: float = Query(..., description="Новый баланс партнёра"),
|
||||
percent: float = Query(..., description="Новый процент партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет данные партнёра (баланс и процент).
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
stmt = text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_balance = :balance, partner_percent = :percent
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
|
||||
status_code=200,
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tg_id}")
|
||||
async def get_partner_data(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает партнёрские данные для указанного `tg_id`.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"tg_id": int,
|
||||
"partner_balance": float,
|
||||
"partner_percent": float,
|
||||
"partner_code": str | None,
|
||||
"payout_method": str | None,
|
||||
"invited": [
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
meta_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
COALESCE(u.partner_percent, 0) AS partner_percent,
|
||||
u.partner_code,
|
||||
u.payout_method
|
||||
FROM users u
|
||||
WHERE u.tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
|
||||
meta_row = meta_res.fetchone()
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
response = {
|
||||
"tg_id": tg_id,
|
||||
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
|
||||
"partner_percent": float(meta_row[1] or 0) if meta_row else 0.0,
|
||||
"partner_code": meta_row[2] if meta_row else None,
|
||||
"payout_method": meta_row[3] if meta_row else None,
|
||||
"invited": [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
],
|
||||
}
|
||||
|
||||
return JSONResponse(content=response)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/invited")
|
||||
async def get_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список приглашённых пользователей конкретного партнёра.
|
||||
|
||||
Структура ответа:
|
||||
[
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
"""
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
invited_list = [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
]
|
||||
|
||||
return JSONResponse(content=invited_list)
|
||||
+9
-1
@@ -8,7 +8,14 @@ from database.models import Coupon, CouponUsage
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_coupon(session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None) -> bool:
|
||||
async def create_coupon(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
amount: int,
|
||||
usage_limit: int,
|
||||
days: int = None,
|
||||
new_users_only: bool = False,
|
||||
) -> bool:
|
||||
try:
|
||||
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
|
||||
if exists:
|
||||
@@ -23,6 +30,7 @@ async def create_coupon(session: AsyncSession, code: str, amount: int, usage_lim
|
||||
usage_count=0,
|
||||
is_used=False,
|
||||
days=days,
|
||||
new_users_only=new_users_only,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -183,6 +183,7 @@ class Coupon(DictLikeMixin, Base):
|
||||
usage_count = Column(Integer, default=0)
|
||||
is_used = Column(Boolean, default=False)
|
||||
days = Column(Integer, nullable=True)
|
||||
new_users_only = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class CouponUsage(DictLikeMixin, Base):
|
||||
|
||||
+7
-10
@@ -278,9 +278,9 @@ async def resolve_device_limit_from_group(session: AsyncSession, server_id: str)
|
||||
|
||||
|
||||
async def filter_cluster_by_subgroup(
|
||||
session: AsyncSession,
|
||||
cluster: list,
|
||||
target_subgroup: str,
|
||||
session: AsyncSession,
|
||||
cluster: list,
|
||||
target_subgroup: str,
|
||||
cluster_id: str,
|
||||
tariff_id: int | None = None,
|
||||
) -> list:
|
||||
@@ -320,7 +320,7 @@ async def filter_cluster_by_subgroup(
|
||||
check_values = [target_subgroup]
|
||||
if tariff_id:
|
||||
check_values.append(str(tariff_id))
|
||||
|
||||
|
||||
total_bindings = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title.in_(check_values))
|
||||
)
|
||||
@@ -345,9 +345,7 @@ async def filter_cluster_by_subgroup(
|
||||
return cluster
|
||||
|
||||
|
||||
async def filter_cluster_by_tariff(
|
||||
session: AsyncSession, cluster: list, tariff_id: int, cluster_id: str
|
||||
) -> list:
|
||||
async def filter_cluster_by_tariff(session: AsyncSession, cluster: list, tariff_id: int, cluster_id: str) -> list:
|
||||
names = [s.get("server_name") for s in cluster if s.get("server_name")]
|
||||
if not names:
|
||||
return []
|
||||
@@ -394,10 +392,9 @@ async def filter_cluster_by_tariff(
|
||||
async def has_legacy_subgroup_bindings(session: AsyncSession, server_ids: list[int]) -> bool:
|
||||
if not server_ids:
|
||||
return False
|
||||
|
||||
|
||||
result = await session.execute(
|
||||
select(ServerSubgroup.subgroup_title)
|
||||
.where(ServerSubgroup.server_id.in_(server_ids))
|
||||
select(ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(server_ids))
|
||||
)
|
||||
for (title,) in result.all():
|
||||
if title and not title.isdigit():
|
||||
|
||||
+1
-3
@@ -225,9 +225,7 @@ async def mark_trial_extended(tg_id: int, session: AsyncSession):
|
||||
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
|
||||
keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery()
|
||||
|
||||
res = await session.execute(
|
||||
select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id)
|
||||
)
|
||||
res = await session.execute(select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id))
|
||||
row = res.first()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
bot:
|
||||
container_name: solobot
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /etc/machine-id:/etc/machine-id:ro
|
||||
- /var/lib/dbus/machine-id:/var/lib/dbus/machine-id:ro
|
||||
|
||||
@@ -149,16 +149,13 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
|
||||
|
||||
servers = await get_servers(session=session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
|
||||
if not cluster_servers:
|
||||
await message.answer("❌ Не найдены серверы в кластере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
is_full_remnawave = all(
|
||||
str(s.get("panel_type", "")).lower() == "remnawave"
|
||||
for s in cluster_servers
|
||||
)
|
||||
is_full_remnawave = all(str(s.get("panel_type", "")).lower() == "remnawave" for s in cluster_servers)
|
||||
|
||||
if is_full_remnawave:
|
||||
uuids = [key.client_id for key in keys if key.client_id]
|
||||
@@ -175,6 +172,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
|
||||
return
|
||||
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
remna = RemnawaveAPI(api_url)
|
||||
|
||||
try:
|
||||
|
||||
@@ -293,7 +293,7 @@ async def handle_sync_server(
|
||||
has_new_binding = tid and tid in (server_info.get("tariff_ids") or [])
|
||||
has_old_binding = subgroup and subgroup in (server_info.get("tariff_subgroups") or [])
|
||||
has_any_binding = bool(server_info.get("tariff_ids") or server_info.get("tariff_subgroups"))
|
||||
|
||||
|
||||
if has_any_binding and subgroup and not has_new_binding and not has_old_binding:
|
||||
continue
|
||||
|
||||
@@ -565,7 +565,8 @@ async def handle_sync_cluster(
|
||||
if subgroup_title or (tariff and tariff.get("id")):
|
||||
tid = tariff.get("id") if tariff else None
|
||||
filtered_servers = [
|
||||
s for s in cluster_servers
|
||||
s
|
||||
for s in cluster_servers
|
||||
if (tid and tid in (s.get("tariff_ids") or []))
|
||||
or (subgroup_title and subgroup_title in (s.get("tariff_subgroups") or []))
|
||||
]
|
||||
|
||||
@@ -96,12 +96,11 @@ async def show_servers_for_tariffs(
|
||||
reply_markup=build_legacy_reset_kb(cluster_name),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n"
|
||||
f"<i>Кластер: {cluster_name}</i>",
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
@@ -135,8 +134,7 @@ async def toggle_server_for_tariffs(
|
||||
selected.add(server_name)
|
||||
await state.update_data({key: list(selected)})
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n"
|
||||
f"<i>Кластер: {cluster_name}</i>",
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
@@ -153,8 +151,7 @@ async def reset_tariff_selection(
|
||||
f"tariff_sel:{cluster_name}": [],
|
||||
})
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n"
|
||||
f"<i>Кластер: {cluster_name}</i>",
|
||||
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, set()),
|
||||
)
|
||||
|
||||
@@ -185,7 +182,7 @@ async def choose_tariffs(
|
||||
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ Для этой группы нет доступных тарифов.")
|
||||
return
|
||||
@@ -193,8 +190,7 @@ async def choose_tariffs(
|
||||
selected_tariffs = set(data.get(f"tariff_sel:{cluster_name}", []))
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n"
|
||||
f"<i>Кластер: {cluster_name}</i>",
|
||||
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, selected_tariffs),
|
||||
)
|
||||
|
||||
@@ -205,35 +201,34 @@ async def toggle_tariff_selection(
|
||||
):
|
||||
cluster_name, tariff_id_str = callback_data.data.split("|", 1)
|
||||
tariff_id = int(tariff_id_str)
|
||||
|
||||
|
||||
key = f"tariff_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected_tariffs = set(data.get(key, []))
|
||||
|
||||
|
||||
if tariff_id in selected_tariffs:
|
||||
selected_tariffs.remove(tariff_id)
|
||||
else:
|
||||
selected_tariffs.add(tariff_id)
|
||||
|
||||
|
||||
await state.update_data({key: list(selected_tariffs)})
|
||||
|
||||
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
|
||||
group_codes = [r[0] for r in res.fetchall() if r[0]]
|
||||
if not group_codes:
|
||||
return
|
||||
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_codes[0], Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
|
||||
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n"
|
||||
f"<i>Кластер: {cluster_name}</i>",
|
||||
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
|
||||
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, selected_tariffs),
|
||||
)
|
||||
|
||||
@@ -245,42 +240,41 @@ async def apply_tariffs(
|
||||
try:
|
||||
cluster_name = callback_data.data
|
||||
data = await state.get_data()
|
||||
|
||||
|
||||
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
|
||||
selected_tariffs = set(data.get(f"tariff_sel:{cluster_name}", []))
|
||||
|
||||
|
||||
if not selected_servers:
|
||||
await callback.answer("Не выбраны серверы", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
if not selected_tariffs:
|
||||
await callback.answer("Не выбраны тарифы", show_alert=True)
|
||||
return
|
||||
|
||||
servers_q = await session.execute(
|
||||
select(Server.id, Server.server_name, Server.tariff_group)
|
||||
.where(Server.server_name.in_(selected_servers))
|
||||
select(Server.id, Server.server_name, Server.tariff_group).where(Server.server_name.in_(selected_servers))
|
||||
)
|
||||
servers_data = servers_q.fetchall()
|
||||
server_ids = [row[0] for row in servers_data]
|
||||
group_code = servers_data[0][2] if servers_data else "standard"
|
||||
|
||||
|
||||
if not server_ids:
|
||||
await callback.answer("Серверы не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
selected_tariff_strs = {str(tid) for tid in selected_tariffs}
|
||||
|
||||
|
||||
await session.execute(
|
||||
delete(ServerSubgroup)
|
||||
.where(ServerSubgroup.server_id.in_(server_ids))
|
||||
.where(ServerSubgroup.subgroup_title.regexp_match(r'^\d+$'))
|
||||
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
|
||||
.where(ServerSubgroup.subgroup_title.notin_(selected_tariff_strs))
|
||||
)
|
||||
|
||||
for tariff_id in selected_tariffs:
|
||||
tariff_id_str = str(tariff_id)
|
||||
|
||||
|
||||
existing_q = await session.execute(
|
||||
select(ServerSubgroup.server_id)
|
||||
.where(ServerSubgroup.server_id.in_(server_ids))
|
||||
@@ -288,13 +282,13 @@ async def apply_tariffs(
|
||||
)
|
||||
already = {r[0] for r in existing_q.fetchall()}
|
||||
to_insert = [sid for sid in server_ids if sid not in already]
|
||||
|
||||
|
||||
if to_insert:
|
||||
session.add_all([
|
||||
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=tariff_id_str)
|
||||
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=tariff_id_str)
|
||||
for sid in to_insert
|
||||
])
|
||||
|
||||
|
||||
await session.commit()
|
||||
|
||||
await state.update_data({
|
||||
@@ -308,7 +302,7 @@ async def apply_tariffs(
|
||||
all_tariff_ids = set()
|
||||
for s in cluster_servers:
|
||||
all_tariff_ids.update(s.get("tariff_ids") or [])
|
||||
|
||||
|
||||
tariffs_cache = {}
|
||||
if all_tariff_ids:
|
||||
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
|
||||
@@ -319,7 +313,7 @@ async def apply_tariffs(
|
||||
"subgroup_title": t.subgroup_title,
|
||||
"group_code": t.group_code,
|
||||
}
|
||||
|
||||
|
||||
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
@@ -359,14 +353,11 @@ async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminC
|
||||
|
||||
|
||||
def render_attach_tariff_menu_text(
|
||||
cluster_name: str,
|
||||
cluster_servers: list[dict],
|
||||
tariffs_cache: dict[int, dict] | None = None
|
||||
cluster_name: str, cluster_servers: list[dict], tariffs_cache: dict[int, dict] | None = None
|
||||
) -> str:
|
||||
|
||||
tariff_map: dict[int, list[str]] = {}
|
||||
legacy_map: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
for s in cluster_servers:
|
||||
server_name = s["server_name"]
|
||||
|
||||
@@ -393,10 +384,10 @@ def render_attach_tariff_menu_text(
|
||||
subgroup = tariff.get("subgroup_title")
|
||||
name = tariff.get("name", f"ID:{tid}")
|
||||
grouped.setdefault(subgroup, []).append((tid, name, servers))
|
||||
|
||||
|
||||
tariff_lines = []
|
||||
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
|
||||
|
||||
|
||||
for subgroup in subgroups_sorted:
|
||||
tariffs_list = grouped[subgroup]
|
||||
if subgroup:
|
||||
@@ -408,7 +399,7 @@ def render_attach_tariff_menu_text(
|
||||
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
|
||||
servers_str = ", ".join(sorted(set(servers)))
|
||||
tariff_lines.append(f"• {name}: {servers_str}")
|
||||
|
||||
|
||||
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
|
||||
elif tariff_map:
|
||||
tariff_lines = []
|
||||
@@ -453,12 +444,10 @@ async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSessi
|
||||
all_tariff_ids = set()
|
||||
for s in cluster_servers:
|
||||
all_tariff_ids.update(s.get("tariff_ids") or [])
|
||||
|
||||
|
||||
tariffs_cache = {}
|
||||
if all_tariff_ids:
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.id.in_(all_tariff_ids))
|
||||
)
|
||||
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
|
||||
for t in result.scalars().all():
|
||||
tariffs_cache[t.id] = {
|
||||
"id": t.id,
|
||||
|
||||
@@ -191,7 +191,7 @@ def build_tariff_selection_kb(cluster_name: str, tariffs: list, selected: set[in
|
||||
grouped.setdefault(subgroup, []).append(t)
|
||||
|
||||
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
|
||||
|
||||
|
||||
for subgroup in subgroups_sorted:
|
||||
tariffs_list = grouped[subgroup]
|
||||
|
||||
@@ -208,10 +208,7 @@ def build_tariff_selection_kb(cluster_name: str, tariffs: list, selected: set[in
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{mark} {t.name}",
|
||||
callback_data=AdminClusterCallback(
|
||||
action="toggle_tariff",
|
||||
data=f"{cluster_name}|{t.id}"
|
||||
).pack(),
|
||||
callback_data=AdminClusterCallback(action="toggle_tariff", data=f"{cluster_name}|{t.id}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -227,7 +224,7 @@ def build_tariff_selection_kb(cluster_name: str, tariffs: list, selected: set[in
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ router = Router()
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_type = State()
|
||||
waiting_for_coupon_audience = State()
|
||||
waiting_for_balance_data = State()
|
||||
waiting_for_days_data = State()
|
||||
|
||||
@@ -62,30 +63,59 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_balance")
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
)
|
||||
async def show_coupon_audience_step(callback_query: CallbackQuery, state: FSMContext, coupon_type: str):
|
||||
await state.update_data(coupon_type=coupon_type)
|
||||
|
||||
text = "🎯 <b>Кому доступен купон?</b>"
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="👤 Всем", callback_data="coupon_audience_all")
|
||||
kb.button(text="🆕 Только новым", callback_data="coupon_audience_new")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_audience)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days")
|
||||
@router.callback_query(F.data == "coupon_type_balance", IsAdminFilter())
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
await show_coupon_audience_step(callback_query, state, "balance")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days", IsAdminFilter())
|
||||
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
await show_coupon_audience_step(callback_query, state, "days")
|
||||
|
||||
|
||||
@router.callback_query(F.data.in_(("coupon_audience_all", "coupon_audience_new")), IsAdminFilter())
|
||||
async def handle_coupon_audience(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
coupon_type = data.get("coupon_type")
|
||||
if coupon_type not in ("balance", "days"):
|
||||
await callback_query.answer("Ошибка: тип купона не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(new_users_only=callback_query.data == "coupon_audience_new")
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if coupon_type == "balance":
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
return
|
||||
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_days_data)
|
||||
|
||||
@@ -97,6 +127,7 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
@@ -119,13 +150,29 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
|
||||
data = await state.get_data()
|
||||
new_users_only = bool(data.get("new_users_only"))
|
||||
|
||||
ok = await create_coupon(
|
||||
session,
|
||||
coupon_code,
|
||||
coupon_amount,
|
||||
usage_limit,
|
||||
days=None,
|
||||
new_users_only=new_users_only,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🎯 Доступ: <b>{audience_txt}</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
@@ -150,6 +197,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
@@ -172,13 +220,29 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
|
||||
data = await state.get_data()
|
||||
new_users_only = bool(data.get("new_users_only"))
|
||||
|
||||
ok = await create_coupon(
|
||||
session,
|
||||
coupon_code,
|
||||
0,
|
||||
usage_limit,
|
||||
days=days,
|
||||
new_users_only=new_users_only,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"⏳ <b>{format_days(days)}</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🎯 Доступ: <b>{audience_txt}</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -481,9 +481,7 @@ def build_user_gifts_kb(tg_id: int, gifts: list, page: int = 0) -> InlineKeyboar
|
||||
callback_data=f"user_gift_page|{tg_id}|{page - 1}",
|
||||
)
|
||||
)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop"))
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -29,10 +29,7 @@ async def show_gifts_list(message: types.Message, session: AsyncSession, tg_id:
|
||||
gifts = await get_user_gifts(session, tg_id)
|
||||
|
||||
if not gifts:
|
||||
text = (
|
||||
f"🎁 <b>Подарки пользователя</b> <code>{tg_id}</code>\n\n"
|
||||
f"У пользователя нет созданных подарков."
|
||||
)
|
||||
text = f"🎁 <b>Подарки пользователя</b> <code>{tg_id}</code>\n\nУ пользователя нет созданных подарков."
|
||||
await message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_user_gifts_kb(tg_id, [], page),
|
||||
@@ -40,6 +37,7 @@ async def show_gifts_list(message: types.Message, session: AsyncSession, tg_id:
|
||||
return
|
||||
|
||||
from .keyboard import GIFTS_PER_PAGE
|
||||
|
||||
start_idx = page * GIFTS_PER_PAGE
|
||||
end_idx = start_idx + GIFTS_PER_PAGE
|
||||
page_gifts = gifts[start_idx:end_idx]
|
||||
@@ -61,11 +59,7 @@ async def show_gifts_list(message: types.Message, session: AsyncSession, tg_id:
|
||||
|
||||
created_str = gift.created_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
lines.append(
|
||||
f"\n<b>{i}.🎁 </b> {gift.selected_months} мес.\n"
|
||||
f" 📅 Создан: {created_str}\n"
|
||||
f" {status}"
|
||||
)
|
||||
lines.append(f"\n<b>{i}.🎁 </b> {gift.selected_months} мес.\n 📅 Создан: {created_str}\n {status}")
|
||||
|
||||
lines.append("\n\n<i>Нажмите кнопку для удаления:</i>")
|
||||
|
||||
|
||||
@@ -110,7 +110,12 @@ async def handle_key_edit(
|
||||
if key_obj.tariff_id:
|
||||
result = await session.execute(
|
||||
select(
|
||||
Tariff.name, Tariff.subgroup_title, Tariff.group_code, Tariff.device_limit, Tariff.traffic_limit, Tariff.configurable
|
||||
Tariff.name,
|
||||
Tariff.subgroup_title,
|
||||
Tariff.group_code,
|
||||
Tariff.device_limit,
|
||||
Tariff.traffic_limit,
|
||||
Tariff.configurable,
|
||||
).where(Tariff.id == key_obj.tariff_id)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
@@ -342,7 +342,9 @@ async def process_user_search(
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
stmt_user = select(User.username, User.balance, User.created_at, User.updated_at, User.trial).where(User.tg_id == tg_id)
|
||||
stmt_user = select(User.username, User.balance, User.created_at, User.updated_at, User.trial).where(
|
||||
User.tg_id == tg_id
|
||||
)
|
||||
result_user = await session.execute(stmt_user)
|
||||
user_data = result_user.first()
|
||||
|
||||
|
||||
@@ -207,10 +207,14 @@ async def handle_user_renew_confirm(
|
||||
base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None
|
||||
|
||||
selected_devices = (
|
||||
base_device_limit if base_device_limit is not None else (device_int_options[0] if device_int_options else None)
|
||||
base_device_limit
|
||||
if base_device_limit is not None
|
||||
else (device_int_options[0] if device_int_options else None)
|
||||
)
|
||||
selected_traffic_gb = (
|
||||
base_traffic_gb if base_traffic_gb is not None else (traffic_int_options[0] if traffic_int_options else None)
|
||||
base_traffic_gb
|
||||
if base_traffic_gb is not None
|
||||
else (traffic_int_options[0] if traffic_int_options else None)
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
@@ -343,7 +347,9 @@ async def handle_user_renew_confirm(
|
||||
plan=tariff_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[AdminRenew] renew_key_in_cluster failed: tg_id={tg_id} email={email} tariff_id={tariff_id}: {e}")
|
||||
logger.error(
|
||||
f"[AdminRenew] renew_key_in_cluster failed: tg_id={tg_id} email={email} tariff_id={tariff_id}: {e}"
|
||||
)
|
||||
ok = False
|
||||
|
||||
await state.clear()
|
||||
@@ -353,7 +359,9 @@ async def handle_user_renew_confirm(
|
||||
|
||||
callback_data_back = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
|
||||
|
||||
await handle_key_edit(callback_query=callback_query, callback_data=callback_data_back, session=session, update=False)
|
||||
await handle_key_edit(
|
||||
callback_query=callback_query, callback_data=callback_data_back, session=session, update=False
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_renew_devices|"), IsAdminFilter())
|
||||
@@ -662,7 +670,9 @@ async def handle_cfg_renew_apply(callback_query: CallbackQuery, session: AsyncSe
|
||||
plan=tariff_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[AdminRenewCfg] renew_key_in_cluster failed: tg_id={tg_id} email={email} tariff_id={tariff_id}: {e}")
|
||||
logger.error(
|
||||
f"[AdminRenewCfg] renew_key_in_cluster failed: tg_id={tg_id} email={email} tariff_id={tariff_id}: {e}"
|
||||
)
|
||||
ok = False
|
||||
|
||||
await state.clear()
|
||||
@@ -672,7 +682,9 @@ async def handle_cfg_renew_apply(callback_query: CallbackQuery, session: AsyncSe
|
||||
|
||||
callback_data_back = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=int(tg_id))
|
||||
|
||||
await handle_key_edit(callback_query=callback_query, callback_data=callback_data_back, session=session, update=False)
|
||||
await handle_key_edit(
|
||||
callback_query=callback_query, callback_data=callback_data_back, session=session, update=False
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back:group", IsAdminFilter())
|
||||
|
||||
+17
-1
@@ -107,6 +107,15 @@ async def activate_coupon(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
from database.models import User
|
||||
|
||||
if getattr(coupon, "new_users_only", False):
|
||||
exists = await session.scalar(select(User.tg_id).where(User.tg_id == user_id))
|
||||
if exists is not None:
|
||||
await message.answer("❌ Этот купон доступен только для новых пользователей.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if isinstance(user, dict):
|
||||
await add_user(session=session, **user)
|
||||
else:
|
||||
@@ -184,7 +193,7 @@ async def handle_key_extension(
|
||||
session: AsyncSession,
|
||||
admin: bool = False,
|
||||
):
|
||||
from database.models import Coupon, Key
|
||||
from database.models import Coupon, Key, User
|
||||
|
||||
parts = callback_query.data.split("|")
|
||||
client_id = parts[1]
|
||||
@@ -205,6 +214,13 @@ async def handle_key_extension(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if getattr(coupon, "new_users_only", False):
|
||||
exists = await session.scalar(select(User.tg_id).where(User.tg_id == tg_id))
|
||||
if exists is not None:
|
||||
await callback_query.message.edit_text("❌ Этот купон доступен только для новых пользователей.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
|
||||
key = result.scalar_one_or_none()
|
||||
if not key or key.is_frozen:
|
||||
|
||||
@@ -340,12 +340,14 @@ async def build_key_view_payload(session: AsyncSession, key_name: str):
|
||||
remna_server = None
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
for srv in cluster_servers:
|
||||
if (srv.get("server_name") == server_name or cluster_name == server_name) and srv.get("panel_type") == "remnawave":
|
||||
if (srv.get("server_name") == server_name or cluster_name == server_name) and srv.get(
|
||||
"panel_type"
|
||||
) == "remnawave":
|
||||
remna_server = srv
|
||||
break
|
||||
if remna_server:
|
||||
break
|
||||
|
||||
|
||||
if remna_server:
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
|
||||
@@ -97,9 +97,7 @@ async def create_key_on_cluster(
|
||||
device_limit_value = int(hwid_limit or 0)
|
||||
|
||||
if plan is not None:
|
||||
filtered = await filter_cluster_by_tariff(
|
||||
session, enabled_servers, plan, cluster_id
|
||||
)
|
||||
filtered = await filter_cluster_by_tariff(session, enabled_servers, plan, cluster_id)
|
||||
if filtered is not enabled_servers:
|
||||
enabled_servers = filtered
|
||||
elif subgroup_title:
|
||||
|
||||
@@ -78,7 +78,9 @@ async def renew_on_remnawave(
|
||||
if old_device_limit is not None and hwid_device_limit < old_device_limit:
|
||||
try:
|
||||
await remna.clear_all_hwid_devices(client_id)
|
||||
logger.info(f"{PANEL_REMNA} HWID устройства сброшены для {client_id} (лимит {old_device_limit} → {hwid_device_limit})")
|
||||
logger.info(
|
||||
f"{PANEL_REMNA} HWID устройства сброшены для {client_id} (лимит {old_device_limit} → {hwid_device_limit})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"{PANEL_REMNA} Ошибка сброса HWID: {e}")
|
||||
|
||||
@@ -298,9 +300,7 @@ async def renew_key_in_cluster(
|
||||
cluster_scope = [single_server]
|
||||
else:
|
||||
if plan is not None:
|
||||
filtered = await filter_cluster_by_tariff(
|
||||
session, cluster, plan, cluster_id
|
||||
)
|
||||
filtered = await filter_cluster_by_tariff(session, cluster, plan, cluster_id)
|
||||
if filtered is not cluster:
|
||||
cluster_scope = filtered
|
||||
elif target_subgroup:
|
||||
@@ -310,9 +310,7 @@ async def renew_key_in_cluster(
|
||||
else:
|
||||
cluster_scope = cluster
|
||||
elif target_subgroup:
|
||||
target = await filter_cluster_by_subgroup(
|
||||
session, cluster, target_subgroup, cluster_id, tariff_id=plan
|
||||
)
|
||||
target = await filter_cluster_by_subgroup(session, cluster, target_subgroup, cluster_id, tariff_id=plan)
|
||||
cluster_scope = target if target else cluster
|
||||
else:
|
||||
cluster_scope = cluster
|
||||
|
||||
@@ -53,9 +53,7 @@ async def update_key_on_cluster(
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
if tariff_id is not None:
|
||||
filtered = await filter_cluster_by_tariff(
|
||||
session, cluster, tariff_id, cluster_id
|
||||
)
|
||||
filtered = await filter_cluster_by_tariff(session, cluster, tariff_id, cluster_id)
|
||||
if filtered is not cluster:
|
||||
cluster = filtered
|
||||
elif subgroup_code:
|
||||
@@ -63,9 +61,7 @@ async def update_key_on_cluster(
|
||||
session, cluster, subgroup_code, cluster_id, tariff_id=tariff_id
|
||||
)
|
||||
elif subgroup_code:
|
||||
cluster = await filter_cluster_by_subgroup(
|
||||
session, cluster, subgroup_code, cluster_id, tariff_id=tariff_id
|
||||
)
|
||||
cluster = await filter_cluster_by_subgroup(session, cluster, subgroup_code, cluster_id, tariff_id=tariff_id)
|
||||
|
||||
if not cluster:
|
||||
logger.warning(f"[Update] Нет серверов после фильтрации по привязкам в кластере {cluster_id}")
|
||||
@@ -276,9 +272,7 @@ async def update_subscription(
|
||||
cluster_servers = []
|
||||
|
||||
if tariff_id is not None:
|
||||
filtered = await filter_cluster_by_tariff(
|
||||
session, cluster_servers, tariff_id, new_cluster_id
|
||||
)
|
||||
filtered = await filter_cluster_by_tariff(session, cluster_servers, tariff_id, new_cluster_id)
|
||||
if filtered is not cluster_servers:
|
||||
cluster_servers = filtered
|
||||
elif subgroup_code:
|
||||
@@ -291,9 +285,7 @@ async def update_subscription(
|
||||
)
|
||||
|
||||
if not cluster_servers:
|
||||
logger.warning(
|
||||
f"[Update] Пересоздание пропущено: нет серверов после фильтрации в {new_cluster_id}."
|
||||
)
|
||||
logger.warning(f"[Update] Пересоздание пропущено: нет серверов после фильтрации в {new_cluster_id}.")
|
||||
return
|
||||
|
||||
if tariff:
|
||||
|
||||
@@ -78,12 +78,12 @@ class NotificationContext:
|
||||
current_time: int
|
||||
preload_data: Optional[dict] = None
|
||||
bulk_updates: Optional[dict] = None
|
||||
|
||||
|
||||
def get_balance(self, tg_id: int) -> float:
|
||||
if self.preload_data and tg_id in self.preload_data.get("balances_cache", {}):
|
||||
return self.preload_data["balances_cache"][tg_id]
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_tariff(self, tariff_id: int) -> Optional[dict]:
|
||||
if self.preload_data and tariff_id in self.preload_data.get("tariffs_cache", {}):
|
||||
return self.preload_data["tariffs_cache"][tariff_id]
|
||||
@@ -139,26 +139,18 @@ async def execute_bulk_updates(session: AsyncSession, bulk_updates: dict[str, An
|
||||
for tg_id, balance_change in bulk_updates["balance_changes"].items():
|
||||
await session.execute(
|
||||
text("UPDATE users SET balance = balance + :change WHERE tg_id = :tg_id"),
|
||||
{"change": balance_change, "tg_id": tg_id}
|
||||
{"change": balance_change, "tg_id": tg_id},
|
||||
)
|
||||
logger.info(f"Bulk: обновлено {len(bulk_updates['balance_changes'])} балансов")
|
||||
|
||||
if bulk_updates["key_expiry_updates"]:
|
||||
for client_id, new_expiry in bulk_updates["key_expiry_updates"]:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.client_id == client_id)
|
||||
.values(expiry_time=new_expiry)
|
||||
)
|
||||
await session.execute(update(Key).where(Key.client_id == client_id).values(expiry_time=new_expiry))
|
||||
logger.info(f"Bulk: обновлено {len(bulk_updates['key_expiry_updates'])} сроков действия ключей")
|
||||
|
||||
if bulk_updates["key_tariff_updates"]:
|
||||
for client_id, new_tariff_id in bulk_updates["key_tariff_updates"]:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.client_id == client_id)
|
||||
.values(tariff_id=new_tariff_id)
|
||||
)
|
||||
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=new_tariff_id))
|
||||
logger.info(f"Bulk: обновлено {len(bulk_updates['key_tariff_updates'])} тарифов ключей")
|
||||
|
||||
for tg_id, notification_type in bulk_updates["notifications_to_add"]:
|
||||
@@ -184,9 +176,9 @@ async def execute_bulk_updates(session: AsyncSession, bulk_updates: dict[str, An
|
||||
async def send_expiry_warning(ctx: NotificationContext, key, hours_left: int, photo: str) -> bool:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
|
||||
|
||||
expiry_data = await prepare_key_expiry_data(key, ctx.session, ctx.current_time)
|
||||
|
||||
|
||||
message_text = KEY_EXPIRY.format(
|
||||
email=email,
|
||||
hours_left_formatted=expiry_data["hours_left_formatted"],
|
||||
@@ -194,7 +186,7 @@ async def send_expiry_warning(ctx: NotificationContext, key, hours_left: int, ph
|
||||
tariff_name=expiry_data["tariff_name"],
|
||||
tariff_details=expiry_data["tariff_details"],
|
||||
)
|
||||
|
||||
|
||||
keyboard = build_notification_kb(email)
|
||||
return await send_notification(ctx.bot, tg_id, photo, message_text, keyboard)
|
||||
|
||||
@@ -202,9 +194,9 @@ async def send_expiry_warning(ctx: NotificationContext, key, hours_left: int, ph
|
||||
async def send_cannot_renew(ctx: NotificationContext, key, photo: str) -> bool:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
|
||||
|
||||
expiry_data = await prepare_key_expiry_data(key, ctx.session, ctx.current_time)
|
||||
|
||||
|
||||
message_text = KEY_CANNOT_RENEW_CURRENT.format(
|
||||
email=email,
|
||||
hours_left_formatted=expiry_data["hours_left_formatted"],
|
||||
@@ -212,7 +204,7 @@ async def send_cannot_renew(ctx: NotificationContext, key, photo: str) -> bool:
|
||||
tariff_name=expiry_data["tariff_name"],
|
||||
tariff_details=expiry_data["tariff_details"],
|
||||
)
|
||||
|
||||
|
||||
keyboard = build_change_tariff_kb(email)
|
||||
return await send_notification(ctx.bot, tg_id, photo, message_text, keyboard)
|
||||
|
||||
@@ -220,7 +212,7 @@ async def send_cannot_renew(ctx: NotificationContext, key, photo: str) -> bool:
|
||||
async def send_expired_notification(ctx: NotificationContext, key, delay_minutes: int) -> bool:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
|
||||
|
||||
if delay_minutes > 0:
|
||||
hours = delay_minutes // 60
|
||||
minutes = delay_minutes % 60
|
||||
@@ -233,7 +225,7 @@ async def send_expired_notification(ctx: NotificationContext, key, delay_minutes
|
||||
message_text = KEY_EXPIRED_DELAY_MSG.format(email=email, time_formatted=time_formatted)
|
||||
else:
|
||||
message_text = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
|
||||
|
||||
|
||||
keyboard = build_notification_kb(email)
|
||||
return await send_notification(ctx.bot, tg_id, "notify_expired.jpg", message_text, keyboard)
|
||||
|
||||
@@ -241,7 +233,7 @@ async def send_expired_notification(ctx: NotificationContext, key, delay_minutes
|
||||
async def send_deleted_notification(ctx: NotificationContext, key) -> bool:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
|
||||
|
||||
message_text = KEY_DELETED_MSG.format(email=email)
|
||||
keyboard = build_notification_expired_kb()
|
||||
return await send_notification(ctx.bot, tg_id, "notify_expired.jpg", message_text, keyboard)
|
||||
@@ -250,11 +242,11 @@ async def send_deleted_notification(ctx: NotificationContext, key) -> bool:
|
||||
async def send_renewed_notification(ctx: NotificationContext, key, tariff: dict, new_expiry_time: int) -> bool:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
|
||||
|
||||
selected_device_limit = getattr(key, "selected_device_limit", None)
|
||||
selected_traffic_limit = getattr(key, "selected_traffic_limit", None)
|
||||
selected_traffic_gb = int(selected_traffic_limit) if selected_traffic_limit is not None else None
|
||||
|
||||
|
||||
device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key(
|
||||
session=ctx.session,
|
||||
tariff_id=int(tariff["id"]),
|
||||
@@ -262,13 +254,13 @@ async def send_renewed_notification(ctx: NotificationContext, key, tariff: dict,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0
|
||||
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
formatted_expiry_date = formatted_expiry_date.replace(
|
||||
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
|
||||
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
|
||||
)
|
||||
|
||||
|
||||
message_text = get_renewal_message(
|
||||
tariff_name=tariff["name"],
|
||||
traffic_limit=traffic_limit_gb,
|
||||
@@ -276,15 +268,15 @@ async def send_renewed_notification(ctx: NotificationContext, key, tariff: dict,
|
||||
expiry_date=formatted_expiry_date,
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
)
|
||||
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
result = await send_notification(ctx.bot, tg_id, "notify_expired.jpg", message_text, keyboard)
|
||||
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -292,34 +284,34 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, Optional[
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
renew_notification_id = f"{email}_renew"
|
||||
|
||||
|
||||
can_renew = await check_notification_time(ctx.session, tg_id, renew_notification_id, hours=24)
|
||||
if not can_renew:
|
||||
logger.debug(f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов.")
|
||||
return False, None, None
|
||||
|
||||
|
||||
if ctx.preload_data and tg_id in ctx.preload_data.get("balances_cache", {}):
|
||||
balance = ctx.preload_data["balances_cache"][tg_id]
|
||||
else:
|
||||
balance = await get_balance(ctx.session, tg_id)
|
||||
|
||||
|
||||
server_id = key.server_id
|
||||
tariff_id = key.tariff_id
|
||||
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(ctx.session, server_id)
|
||||
if not tariffs:
|
||||
logger.warning(f"⛔ Нет доступных тарифов для продления подписки {email}")
|
||||
return False, None, None
|
||||
|
||||
|
||||
current_tariff = None
|
||||
if tariff_id:
|
||||
current_tariff = ctx.get_tariff(tariff_id)
|
||||
if not current_tariff and await check_tariff_exists(ctx.session, tariff_id):
|
||||
current_tariff = await get_tariff_by_id(ctx.session, tariff_id)
|
||||
|
||||
|
||||
if not current_tariff:
|
||||
return False, None, None
|
||||
|
||||
|
||||
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
|
||||
try:
|
||||
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=ctx.session)
|
||||
@@ -328,10 +320,10 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, Optional[
|
||||
forbidden_groups.extend(additional_groups)
|
||||
except Exception as error:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||
|
||||
|
||||
if current_tariff["group_code"] in forbidden_groups:
|
||||
return False, None, None
|
||||
|
||||
|
||||
renewal_cost = await resolve_price_to_charge(
|
||||
ctx.session,
|
||||
{
|
||||
@@ -341,18 +333,18 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, Optional[
|
||||
"selected_price_rub": getattr(key, "selected_price_rub", None),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if renewal_cost is None or balance < renewal_cost:
|
||||
return False, None, None
|
||||
|
||||
|
||||
client_id = key.client_id
|
||||
current_expiry = key.expiry_time
|
||||
duration_days = current_tariff["duration_days"]
|
||||
|
||||
|
||||
selected_device_limit = getattr(key, "selected_device_limit", None)
|
||||
selected_traffic_limit = getattr(key, "selected_traffic_limit", None)
|
||||
selected_traffic_gb = int(selected_traffic_limit) if selected_traffic_limit is not None else None
|
||||
|
||||
|
||||
device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key(
|
||||
session=ctx.session,
|
||||
tariff_id=int(current_tariff["id"]),
|
||||
@@ -360,20 +352,20 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, Optional[
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0
|
||||
|
||||
|
||||
new_expiry_time = (
|
||||
current_expiry
|
||||
if current_expiry > datetime.utcnow().timestamp() * 1000
|
||||
else datetime.utcnow().timestamp() * 1000
|
||||
) + duration_days * 24 * 60 * 60 * 1000
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Продление подписки {email} на {duration_days} дней для пользователя {tg_id}. "
|
||||
f"Баланс: {balance}, списываем: {renewal_cost}"
|
||||
)
|
||||
|
||||
|
||||
key_subgroup = current_tariff.get("subgroup_title")
|
||||
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
@@ -386,13 +378,13 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, Optional[
|
||||
old_subgroup=key_subgroup,
|
||||
plan=current_tariff["id"],
|
||||
)
|
||||
|
||||
|
||||
if ctx.bulk_updates is not None:
|
||||
if tg_id in ctx.bulk_updates["balance_changes"]:
|
||||
ctx.bulk_updates["balance_changes"][tg_id] -= renewal_cost
|
||||
else:
|
||||
ctx.bulk_updates["balance_changes"][tg_id] = -renewal_cost
|
||||
|
||||
|
||||
ctx.bulk_updates["key_expiry_updates"].append((client_id, int(new_expiry_time)))
|
||||
ctx.bulk_updates["key_tariff_updates"].append((client_id, current_tariff["id"]))
|
||||
ctx.bulk_updates["notifications_to_add"].append((tg_id, renew_notification_id))
|
||||
@@ -401,7 +393,7 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, Optional[
|
||||
await update_key_expiry(ctx.session, client_id, int(new_expiry_time))
|
||||
await update_key_tariff(ctx.session, client_id, current_tariff["id"])
|
||||
await add_notification(ctx.session, tg_id, renew_notification_id)
|
||||
|
||||
|
||||
return True, current_tariff, int(new_expiry_time)
|
||||
|
||||
|
||||
@@ -418,47 +410,47 @@ async def notify_expiring_keys(
|
||||
logger.info(f"Начало проверки подписок, истекающих через {min_hours}-{max_hours} часов.")
|
||||
else:
|
||||
logger.info(f"Начало проверки подписок, истекающих через {max_hours} часов.")
|
||||
|
||||
|
||||
min_threshold = int((datetime.now(moscow_tz) + timedelta(hours=min_hours)).timestamp() * 1000)
|
||||
max_threshold = int((datetime.now(moscow_tz) + timedelta(hours=max_hours)).timestamp() * 1000)
|
||||
expiring_keys = [key for key in keys if key.expiry_time and min_threshold < key.expiry_time <= max_threshold]
|
||||
|
||||
|
||||
if min_hours > 0:
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {min_hours}-{max_hours} часов.")
|
||||
else:
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {max_hours} часов.")
|
||||
|
||||
|
||||
tg_ids = [key.tg_id for key in expiring_keys]
|
||||
emails = [key.email or "" for key in expiring_keys]
|
||||
allowed = await check_notifications_bulk(ctx.session, notify_type, max_hours, tg_ids=tg_ids, emails=emails)
|
||||
allowed_set = {(user["tg_id"], user["email"]) for user 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}_{notify_type}"
|
||||
|
||||
|
||||
can_notify = await check_notification_time(ctx.session, tg_id, notification_id, hours=max_hours)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
|
||||
if notify_renew_enabled:
|
||||
try:
|
||||
renewed, tariff, new_expiry = await try_auto_renew(ctx, key)
|
||||
|
||||
|
||||
if renewed and tariff and new_expiry:
|
||||
await send_renewed_notification(ctx, key, tariff, new_expiry)
|
||||
await add_notification(ctx.session, tg_id, notification_id)
|
||||
else:
|
||||
await send_cannot_renew(ctx, key, photo)
|
||||
await add_notification(ctx.session, tg_id, notification_id)
|
||||
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {error}")
|
||||
else:
|
||||
@@ -479,7 +471,7 @@ async def notify_expiring_keys(
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
})
|
||||
|
||||
|
||||
if messages:
|
||||
results = await send_messages_with_limit(ctx.bot, messages, session=ctx.session)
|
||||
sent_count = 0
|
||||
@@ -487,41 +479,43 @@ async def notify_expiring_keys(
|
||||
await add_notification(ctx.session, msg["tg_id"], msg["notification_id"])
|
||||
if result:
|
||||
sent_count += 1
|
||||
logger.info(f"Отправлено уведомление об истекающей подписке {msg['email']} пользователю {msg['tg_id']}.")
|
||||
logger.info(
|
||||
f"Отправлено уведомление об истекающей подписке {msg['email']} пользователю {msg['tg_id']}."
|
||||
)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений типа {notify_type}.")
|
||||
|
||||
|
||||
logger.info(f"Обработка уведомлений {notify_type} завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def handle_expired_keys(ctx: NotificationContext, keys: list):
|
||||
logger.info("Начало обработки истекших ключей.")
|
||||
|
||||
|
||||
expired_keys = [key for key in keys if key.expiry_time and key.expiry_time < ctx.current_time]
|
||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||
|
||||
|
||||
tg_ids = [key.tg_id for key in expired_keys]
|
||||
emails = [key.email or "" for key in expired_keys]
|
||||
users = await check_notifications_bulk(ctx.session, "key_expired", 0, tg_ids=tg_ids, emails=emails)
|
||||
users_set = {(user["tg_id"], user["email"]) for user in users}
|
||||
|
||||
|
||||
notify_renew_expired_enabled = bool(NOTIFICATIONS_CONFIG.get("RENEW_EXPIRED_ENABLED", NOTIFY_RENEW_EXPIRED))
|
||||
notify_delete_key_enabled = bool(NOTIFICATIONS_CONFIG.get("DELETE_KEY_ENABLED", NOTIFY_DELETE_KEY))
|
||||
delete_key_delay_minutes = int(NOTIFICATIONS_CONFIG.get("DELETE_KEY_DELAY_MINUTES", NOTIFY_DELETE_DELAY))
|
||||
|
||||
|
||||
for key in expired_keys:
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
client_id = key.client_id
|
||||
server_id = key.server_id
|
||||
notification_id = f"{email}_key_expired"
|
||||
|
||||
|
||||
last_notification_time = await get_last_notification_time(ctx.session, tg_id, notification_id)
|
||||
|
||||
|
||||
if notify_renew_expired_enabled:
|
||||
try:
|
||||
renewed, tariff, new_expiry = await try_auto_renew(ctx, key)
|
||||
|
||||
|
||||
if renewed and tariff and new_expiry:
|
||||
await send_renewed_notification(ctx, key, tariff, new_expiry)
|
||||
if ctx.bulk_updates:
|
||||
@@ -529,36 +523,36 @@ async def handle_expired_keys(ctx: NotificationContext, keys: list):
|
||||
else:
|
||||
await delete_notification(ctx.session, tg_id, notification_id)
|
||||
continue
|
||||
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {error}")
|
||||
continue
|
||||
|
||||
|
||||
if notify_delete_key_enabled:
|
||||
should_delete = False
|
||||
|
||||
|
||||
if delete_key_delay_minutes == 0:
|
||||
should_delete = True
|
||||
elif last_notification_time is not None:
|
||||
minutes_passed = (ctx.current_time - last_notification_time) / (1000 * 60)
|
||||
should_delete = minutes_passed >= delete_key_delay_minutes
|
||||
logger.info(f"Прошло минут={minutes_passed:.2f} DELETE_KEY_DELAY_MINUTES={delete_key_delay_minutes}")
|
||||
|
||||
|
||||
if should_delete:
|
||||
try:
|
||||
await delete_key_from_cluster(server_id, email, client_id, ctx.session)
|
||||
await delete_key(ctx.session, client_id)
|
||||
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
|
||||
|
||||
|
||||
await send_deleted_notification(ctx, key)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {error}")
|
||||
continue
|
||||
|
||||
|
||||
if last_notification_time is None and (tg_id, email) in users_set:
|
||||
await send_expired_notification(ctx, key, delete_key_delay_minutes)
|
||||
await add_notification(ctx.session, tg_id, notification_id)
|
||||
|
||||
|
||||
logger.info("Обработка истекших ключей завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -566,20 +560,20 @@ async def handle_expired_keys(ctx: NotificationContext, keys: list):
|
||||
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
while True:
|
||||
notification_interval = int(NOTIFICATIONS_CONFIG.get("BASE_NOTIFICATION_MINUTE", NOTIFICATION_TIME))
|
||||
|
||||
|
||||
if notification_lock.locked():
|
||||
logger.warning("Уведомления уже выполняются. Пропуск...")
|
||||
await asyncio.sleep(notification_interval)
|
||||
continue
|
||||
|
||||
|
||||
async with notification_lock:
|
||||
try:
|
||||
async with sessionmaker() as session:
|
||||
logger.info("Запуск обработки уведомлений")
|
||||
|
||||
|
||||
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
start_time = datetime.now()
|
||||
|
||||
|
||||
try:
|
||||
preload_data = await preload_notification_data(session)
|
||||
keys_data = preload_data["keys_data"]
|
||||
@@ -589,7 +583,7 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
f"Предзагружено данных: {len(keys)} ключей, "
|
||||
f"{len(preload_data['tariffs_cache'])} тарифов за {preload_time:.2f}s"
|
||||
)
|
||||
|
||||
|
||||
bulk_updates = {
|
||||
"balance_changes": {},
|
||||
"key_expiry_updates": [],
|
||||
@@ -597,7 +591,7 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
"notifications_to_add": [],
|
||||
"notifications_to_delete": [],
|
||||
}
|
||||
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при предварительной загрузке данных: {error}")
|
||||
try:
|
||||
@@ -612,7 +606,7 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
keys = []
|
||||
preload_data = None
|
||||
bulk_updates = None
|
||||
|
||||
|
||||
ctx = NotificationContext(
|
||||
bot=bot,
|
||||
session=session,
|
||||
@@ -620,85 +614,93 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
preload_data=preload_data,
|
||||
bulk_updates=bulk_updates,
|
||||
)
|
||||
|
||||
|
||||
trial_time_disable = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
|
||||
if not trial_time_disable:
|
||||
try:
|
||||
await notify_inactive_trial_users(bot, session)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_inactive_trial_users: {error}")
|
||||
|
||||
|
||||
notify_24_enabled = bool(NOTIFICATIONS_CONFIG.get("EXPIRY_24H_ENABLED", NOTIFY_24H_ENABLED))
|
||||
notify_24_hours = int(NOTIFICATIONS_CONFIG.get("EXPIRY_24H_BEFORE_HOURS", NOTIFY_24H_HOURS))
|
||||
notify_10_enabled = bool(NOTIFICATIONS_CONFIG.get("EXPIRY_10H_ENABLED", NOTIFY_10H_ENABLED))
|
||||
notify_10_hours = int(NOTIFICATIONS_CONFIG.get("EXPIRY_10H_BEFORE_HOURS", NOTIFY_10H_HOURS))
|
||||
notify_renew_enabled = bool(NOTIFICATIONS_CONFIG.get("RENEW_ENABLED", NOTIFY_RENEW))
|
||||
inactive_traffic_enabled = bool(NOTIFICATIONS_CONFIG.get("INACTIVE_TRAFFIC_ENABLED", NOTIFY_INACTIVE_TRAFFIC))
|
||||
inactive_traffic_enabled = bool(
|
||||
NOTIFICATIONS_CONFIG.get("INACTIVE_TRAFFIC_ENABLED", NOTIFY_INACTIVE_TRAFFIC)
|
||||
)
|
||||
notify_hot_leads_enabled = bool(NOTIFICATIONS_CONFIG.get("HOT_LEADS_ENABLED", NOTIFY_HOT_LEADS))
|
||||
|
||||
|
||||
if notify_24_enabled:
|
||||
try:
|
||||
await notify_expiring_keys(
|
||||
ctx, keys,
|
||||
ctx,
|
||||
keys,
|
||||
min_hours=notify_10_hours if notify_10_enabled else 0,
|
||||
max_hours=notify_24_hours,
|
||||
notify_type="key_24h",
|
||||
photo="notify_24h.jpg",
|
||||
notify_renew_enabled=notify_renew_enabled
|
||||
notify_type="key_24h",
|
||||
photo="notify_24h.jpg",
|
||||
notify_renew_enabled=notify_renew_enabled,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_expiring_keys (24h): {error}")
|
||||
|
||||
|
||||
if notify_10_enabled:
|
||||
try:
|
||||
await notify_expiring_keys(
|
||||
ctx, keys,
|
||||
ctx,
|
||||
keys,
|
||||
min_hours=0,
|
||||
max_hours=notify_10_hours,
|
||||
notify_type="key_10h",
|
||||
photo="notify_10h.jpg",
|
||||
notify_renew_enabled=notify_renew_enabled
|
||||
notify_type="key_10h",
|
||||
photo="notify_10h.jpg",
|
||||
notify_renew_enabled=notify_renew_enabled,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_expiring_keys (10h): {error}")
|
||||
|
||||
|
||||
try:
|
||||
await handle_expired_keys(ctx, keys)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в handle_expired_keys: {error}")
|
||||
|
||||
|
||||
if inactive_traffic_enabled:
|
||||
try:
|
||||
await notify_users_no_traffic(bot, session, current_time, keys)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_users_no_traffic: {error}")
|
||||
|
||||
|
||||
try:
|
||||
await run_hooks("periodic_notifications", bot=bot, session=session, keys=keys)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в хуках periodic_notifications: {error}")
|
||||
|
||||
|
||||
if notify_hot_leads_enabled:
|
||||
try:
|
||||
await notify_hot_leads(bot, session)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в notify_hot_leads: {error}")
|
||||
|
||||
|
||||
if bulk_updates:
|
||||
bulk_start = datetime.now()
|
||||
await execute_bulk_updates(session, bulk_updates)
|
||||
bulk_time = (datetime.now() - bulk_start).total_seconds()
|
||||
total_renewals = len(bulk_updates["balance_changes"])
|
||||
total_key_updates = len(bulk_updates["key_expiry_updates"]) + len(bulk_updates["key_tariff_updates"])
|
||||
total_notification_updates = len(bulk_updates["notifications_to_add"]) + len(bulk_updates["notifications_to_delete"])
|
||||
total_key_updates = len(bulk_updates["key_expiry_updates"]) + len(
|
||||
bulk_updates["key_tariff_updates"]
|
||||
)
|
||||
total_notification_updates = len(bulk_updates["notifications_to_add"]) + len(
|
||||
bulk_updates["notifications_to_delete"]
|
||||
)
|
||||
logger.info(
|
||||
f"Bulk-операции выполнены за {bulk_time:.2f}s. Обработано: {total_renewals} продлений, {total_key_updates} ключей, {total_notification_updates} уведомлений"
|
||||
)
|
||||
|
||||
|
||||
total_time = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(f"Уведомления завершены за {total_time:.2f}s")
|
||||
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в periodic_notifications: {error}")
|
||||
|
||||
|
||||
await asyncio.sleep(notification_interval)
|
||||
|
||||
@@ -152,6 +152,7 @@ class FastNotificationSender:
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from database.models import BlockedUser
|
||||
|
||||
values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
|
||||
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
|
||||
await self.session.execute(stmt)
|
||||
|
||||
@@ -49,7 +49,7 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
||||
|
||||
users = await check_notifications_bulk(session, "inactive_trial", inactive_hours)
|
||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||
|
||||
|
||||
if not users:
|
||||
logger.info("Проверка пользователей с неактивным пробным периодом завершена.")
|
||||
return
|
||||
@@ -106,22 +106,20 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
||||
source_file="special_notifications",
|
||||
messages_per_second=25,
|
||||
)
|
||||
|
||||
|
||||
sent_tg_ids = []
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
if result:
|
||||
sent_tg_ids.append(msg["tg_id"])
|
||||
|
||||
|
||||
if sent_tg_ids:
|
||||
for tg_id in sent_tg_ids:
|
||||
await add_notification(session, tg_id, "inactive_trial")
|
||||
logger.info(f"Отправлено {len(sent_tg_ids)} уведомлений неактивным пользователям.")
|
||||
|
||||
|
||||
extend_ids = [tg_id for tg_id in users_to_extend if tg_id in sent_tg_ids]
|
||||
if extend_ids:
|
||||
await session.execute(
|
||||
update(User).where(User.tg_id.in_(extend_ids)).values(trial=-1)
|
||||
)
|
||||
await session.execute(update(User).where(User.tg_id.in_(extend_ids)).values(trial=-1))
|
||||
await session.commit()
|
||||
logger.info(f"Bulk: отмечено {len(extend_ids)} пользователей с расширенным триалом")
|
||||
|
||||
@@ -139,12 +137,12 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
||||
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
trial_tariff_ids = {t["id"] for t in trial_tariffs} if trial_tariffs else set()
|
||||
|
||||
|
||||
if not trial_tariff_ids:
|
||||
return
|
||||
|
||||
remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||
|
||||
|
||||
messages = []
|
||||
keys_to_mark_notified = []
|
||||
|
||||
@@ -232,9 +230,7 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
||||
|
||||
if keys_to_mark_notified:
|
||||
try:
|
||||
await session.execute(
|
||||
update(Key).where(Key.client_id.in_(keys_to_mark_notified)).values(notified=True)
|
||||
)
|
||||
await session.execute(update(Key).where(Key.client_id.in_(keys_to_mark_notified)).values(notified=True))
|
||||
await session.commit()
|
||||
logger.info(f"Bulk: отмечено {len(keys_to_mark_notified)} ключей как notified")
|
||||
except Exception as error:
|
||||
|
||||
@@ -3,4 +3,4 @@ ALLOWED_TEMP_PAYMENT_STATES = {
|
||||
"waiting_for_renewal_payment",
|
||||
"waiting_for_gift_payment",
|
||||
"waiting_for_addons_payment",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ def build_currency_choice_kb(
|
||||
kb.row(InlineKeyboardButton(text=RUB_CURRENCY, callback_data=f"{prefix}|RUB"))
|
||||
kb.row(InlineKeyboardButton(text=USD_CURRENCY, callback_data=f"{prefix}|USD"))
|
||||
trib = (TRIBUTE_LINK or "").strip()
|
||||
trib_enabled = (bool(trib) if show_tribute is None else bool(show_tribute) and bool(trib))
|
||||
trib_enabled = bool(trib) if show_tribute is None else bool(show_tribute) and bool(trib)
|
||||
|
||||
if show_stars:
|
||||
row = [InlineKeyboardButton(text=STARS, callback_data=f"{prefix}|STARS")]
|
||||
|
||||
@@ -119,18 +119,10 @@ async def try_fast_payment_flow(
|
||||
|
||||
tribute_cfg = providers_map.get("TRIBUTE") or {}
|
||||
tribute_link = (TRIBUTE_LINK or "").strip()
|
||||
tribute_enabled = (
|
||||
"TRIBUTE" in configured_set
|
||||
and tribute_cfg.get("enabled", True)
|
||||
and bool(tribute_link)
|
||||
)
|
||||
tribute_enabled = "TRIBUTE" in configured_set and tribute_cfg.get("enabled", True) and bool(tribute_link)
|
||||
|
||||
stars_cfg = providers_map.get("STARS") or {}
|
||||
stars_enabled_for_fast = (
|
||||
"STARS" in configured_set
|
||||
and stars_cfg.get("fast")
|
||||
and stars_cfg.get("enabled", True)
|
||||
)
|
||||
stars_enabled_for_fast = "STARS" in configured_set and stars_cfg.get("fast") and stars_cfg.get("enabled", True)
|
||||
|
||||
if multicurrency_mode and not one_screen:
|
||||
show_stars = stars_enabled_for_fast
|
||||
|
||||
Binary file not shown.
@@ -31,9 +31,7 @@ async def handle_pay_heleket_crypto(
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Обработчик оплаты через Heleket криптовалютой."""
|
||||
await process_callback_pay_heleket(
|
||||
callback_query, state, session, method_name="crypto"
|
||||
)
|
||||
await process_callback_pay_heleket(callback_query, state, session, method_name="crypto")
|
||||
|
||||
|
||||
async def handle_custom_amount_input_heleket(
|
||||
@@ -85,34 +83,23 @@ async def handle_custom_amount_input_heleket(
|
||||
method = enabled_methods[0]
|
||||
|
||||
try:
|
||||
payment_url = await generate_heleket_payment_link(
|
||||
amount, tg_id, method
|
||||
)
|
||||
payment_url = await generate_heleket_payment_link(amount, tg_id, method)
|
||||
|
||||
if not payment_url or payment_url == "https://heleket.com/":
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=(
|
||||
"❌ Произошла ошибка при создании платежа. "
|
||||
"Попробуйте позже или выберите другой способ оплаты."
|
||||
),
|
||||
text=("❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты."),
|
||||
)
|
||||
return
|
||||
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=main_menu_text, callback_data="profile"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=main_menu_text, callback_data="profile")],
|
||||
]
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(User.language_code).where(User.tg_id == tg_id)
|
||||
)
|
||||
result = await session.execute(select(User.language_code).where(User.tg_id == tg_id))
|
||||
language_code = result.scalar_one_or_none()
|
||||
amount_text = await format_for_user(
|
||||
session,
|
||||
@@ -123,17 +110,11 @@ async def handle_custom_amount_input_heleket(
|
||||
)
|
||||
text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=message, text=text_out, reply_markup=markup
|
||||
)
|
||||
await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при создании платежа Heleket "
|
||||
f"для пользователя {tg_id}: {e}"
|
||||
)
|
||||
logger.error(f"Ошибка при создании платежа Heleket для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
|
||||
)
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@ router = Router()
|
||||
|
||||
async def get_user_language(session: AsyncSession, tg_id: int) -> str | None:
|
||||
"""Получает язык пользователя из базы данных"""
|
||||
result = await session.execute(
|
||||
select(User.language_code).where(User.tg_id == tg_id)
|
||||
)
|
||||
result = await session.execute(select(User.language_code).where(User.tg_id == tg_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -65,7 +63,13 @@ class ReplenishBalanceHeleket(StatesGroup):
|
||||
|
||||
|
||||
HELEKET_METHODS = {
|
||||
"crypto": {"enable": PROVIDERS_ENABLED.get("HELEKET", False), "currency": "USD", "to_currency": None, "button": HELEKET, "desc": HELEKET_CRYPTO_DESCRIPTION},
|
||||
"crypto": {
|
||||
"enable": PROVIDERS_ENABLED.get("HELEKET", False),
|
||||
"currency": "USD",
|
||||
"to_currency": None,
|
||||
"button": HELEKET,
|
||||
"desc": HELEKET_CRYPTO_DESCRIPTION,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -93,15 +97,13 @@ async def process_callback_pay_heleket(
|
||||
return
|
||||
|
||||
language_code = await get_user_language(session, tg_id)
|
||||
opts = await payment_options_for_user(
|
||||
session, tg_id, language_code, force_currency="USD"
|
||||
)
|
||||
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="USD")
|
||||
builder = build_amounts_keyboard(
|
||||
prefix=f"heleket_{method_name}",
|
||||
pattern="{prefix}_amount|{price}",
|
||||
back_cb="balance",
|
||||
custom_cb=f"heleket_custom_amount|{method_name}",
|
||||
opts=opts
|
||||
opts=opts,
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -120,9 +122,7 @@ async def process_callback_pay_heleket(
|
||||
builder = InlineKeyboardBuilder()
|
||||
for name, method in HELEKET_METHODS.items():
|
||||
if method["enable"]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=method["button"], callback_data=f"heleket_method|{name}")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f"heleket_method|{name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -158,15 +158,13 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
language_code = await get_user_language(session, tg_id)
|
||||
opts = await payment_options_for_user(
|
||||
session, tg_id, language_code, force_currency="USD"
|
||||
)
|
||||
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="USD")
|
||||
builder = build_amounts_keyboard(
|
||||
prefix=f"heleket_{method_name}",
|
||||
pattern="{prefix}_amount|{price}",
|
||||
back_cb="pay",
|
||||
custom_cb=f"heleket_custom_amount|{method_name}",
|
||||
opts=opts
|
||||
opts=opts,
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -188,7 +186,7 @@ async def process_custom_amount_button(callback_query: types.CallbackQuery, stat
|
||||
|
||||
language_code = await get_user_language(session, callback_query.from_user.id)
|
||||
currency = pick_currency(language_code)
|
||||
|
||||
|
||||
currency_text = "рублях (₽)" if currency == "RUB" else "долларах ($)"
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -219,10 +217,10 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext,
|
||||
user_amount = int(message.text.strip())
|
||||
if user_amount <= 0:
|
||||
raise ValueError
|
||||
|
||||
|
||||
min_amount = 1 if currency == "USD" else 10
|
||||
currency_symbol = "$" if currency == "USD" else "₽"
|
||||
|
||||
|
||||
if user_amount < min_amount:
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
@@ -240,7 +238,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext,
|
||||
|
||||
if currency == "RUB":
|
||||
amount_rub = user_amount
|
||||
else:
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session_http:
|
||||
amount_rub = int(await to_rub(user_amount, "USD", session=session_http))
|
||||
|
||||
@@ -407,10 +405,3 @@ async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) -
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Heleket payment: {e}")
|
||||
return "https://heleket.com/"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,24 +34,16 @@ def verify_heleket_signature(data: dict) -> bool:
|
||||
data_without_sign = data.copy()
|
||||
del data_without_sign["sign"]
|
||||
|
||||
json_data = json.dumps(
|
||||
data_without_sign, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
json_data = json.dumps(data_without_sign, ensure_ascii=False, separators=(",", ":"))
|
||||
json_data = json_data.replace("/", "\\/")
|
||||
base64_data = base64.b64encode(json_data.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
base64_data = base64.b64encode(json_data.encode("utf-8")).decode("utf-8")
|
||||
sign_string = base64_data + HELEKET_API_KEY
|
||||
calculated_signature = hashlib.md5(
|
||||
sign_string.encode("utf-8")
|
||||
).hexdigest()
|
||||
calculated_signature = hashlib.md5(sign_string.encode("utf-8")).hexdigest()
|
||||
is_valid = calculated_signature.lower() == received_signature.lower()
|
||||
|
||||
if not is_valid:
|
||||
logger.error(
|
||||
f"Heleket webhook: неверная подпись. "
|
||||
f"Ожидалось: {calculated_signature}, "
|
||||
f"получено: {received_signature}"
|
||||
f"Heleket webhook: неверная подпись. Ожидалось: {calculated_signature}, получено: {received_signature}"
|
||||
)
|
||||
logger.error(f"Heleket webhook: строка для подписи: {sign_string}")
|
||||
else:
|
||||
@@ -82,18 +74,12 @@ async def process_heleket_webhook(data: dict) -> bool:
|
||||
payer_currency = data.get("payer_currency")
|
||||
additional_data = data.get("additional_data")
|
||||
|
||||
logger.info(
|
||||
f"Heleket webhook - Type: {webhook_type}, "
|
||||
f"Order: {order_id}, Status: {status}"
|
||||
)
|
||||
logger.info(f"Heleket webhook - Type: {webhook_type}, Order: {order_id}, Status: {status}")
|
||||
if webhook_type != "payment":
|
||||
logger.warning(f"Heleket webhook: неизвестный тип {webhook_type}")
|
||||
return False
|
||||
if status in ["paid", "paid_over"]:
|
||||
logger.info(
|
||||
f"Heleket: успешный платёж {order_id} на сумму "
|
||||
f"{payment_amount} {payer_currency}"
|
||||
)
|
||||
logger.info(f"Heleket: успешный платёж {order_id} на сумму {payment_amount} {payer_currency}")
|
||||
tg_id = None
|
||||
rub_amount = None
|
||||
if additional_data:
|
||||
@@ -109,24 +95,20 @@ async def process_heleket_webhook(data: dict) -> bool:
|
||||
try:
|
||||
tg_id = int(order_id.split("_")[1])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка извлечения tg_id из order_id: {e}"
|
||||
)
|
||||
logger.error(f"Ошибка извлечения tg_id из order_id: {e}")
|
||||
if not tg_id:
|
||||
logger.error(
|
||||
f"Не удалось извлечь tg_id из Heleket webhook: {data}"
|
||||
)
|
||||
logger.error(f"Не удалось извлечь tg_id из Heleket webhook: {data}")
|
||||
return False
|
||||
balance_amount = (
|
||||
rub_amount if rub_amount else float(merchant_amount)
|
||||
)
|
||||
balance_amount = rub_amount if rub_amount else float(merchant_amount)
|
||||
async with async_session_maker() as session:
|
||||
payment = await get_payment_by_payment_id(session, order_id)
|
||||
if payment:
|
||||
if payment.get("status") == "success":
|
||||
logger.info(f"Heleket: платёж {order_id} уже обработан")
|
||||
return True
|
||||
ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success")
|
||||
ok = await update_payment_status(
|
||||
session=session, internal_id=int(payment["id"]), new_status="success"
|
||||
)
|
||||
if not ok:
|
||||
logger.error(f"Heleket: не удалось обновить статус платежа {order_id}")
|
||||
return False
|
||||
@@ -150,9 +132,7 @@ async def process_heleket_webhook(data: dict) -> bool:
|
||||
)
|
||||
return True
|
||||
elif status in ["fail", "wrong_amount", "cancel", "system_fail"]:
|
||||
logger.warning(
|
||||
f"Heleket: неудачный платёж {order_id}, статус: {status}"
|
||||
)
|
||||
logger.warning(f"Heleket: неудачный платёж {order_id}, статус: {status}")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
payment = await get_payment_by_payment_id(session, order_id)
|
||||
@@ -165,10 +145,7 @@ async def process_heleket_webhook(data: dict) -> bool:
|
||||
await session.commit()
|
||||
return True
|
||||
else:
|
||||
logger.info(
|
||||
f"Heleket: промежуточный статус {status} "
|
||||
f"для платежа {order_id}"
|
||||
)
|
||||
logger.info(f"Heleket: промежуточный статус {status} для платежа {order_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Heleket webhook: {e}")
|
||||
|
||||
@@ -31,9 +31,7 @@ async def handle_pay_kassai_cards(
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Обработчик оплаты через KassaI картами."""
|
||||
await process_callback_pay_kassai(
|
||||
callback_query, state, session, method_name="cards"
|
||||
)
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="cards")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_kassai_sbp")
|
||||
@@ -43,9 +41,7 @@ async def handle_pay_kassai_sbp(
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Обработчик оплаты через KassaI СБП."""
|
||||
await process_callback_pay_kassai(
|
||||
callback_query, state, session, method_name="sbp"
|
||||
)
|
||||
await process_callback_pay_kassai(callback_query, state, session, method_name="sbp")
|
||||
|
||||
|
||||
async def _handle_custom_amount_input_kassai(
|
||||
@@ -94,10 +90,7 @@ async def _handle_custom_amount_input_kassai(
|
||||
|
||||
if amount < min_amount:
|
||||
method_label = method_labels.get(method_name, "")
|
||||
error_msg = (
|
||||
f"❌ Минимальная сумма для оплаты {method_label} — "
|
||||
f"{min_amount}₽."
|
||||
)
|
||||
error_msg = f"❌ Минимальная сумма для оплаты {method_label} — {min_amount}₽."
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=error_msg,
|
||||
@@ -114,34 +107,23 @@ async def _handle_custom_amount_input_kassai(
|
||||
return
|
||||
|
||||
try:
|
||||
payment_url = await generate_kassai_payment_link(
|
||||
amount, tg_id, method
|
||||
)
|
||||
payment_url = await generate_kassai_payment_link(amount, tg_id, method)
|
||||
|
||||
if not payment_url or payment_url == "https://fk.life/":
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=(
|
||||
"❌ Произошла ошибка при создании платежа. "
|
||||
"Попробуйте позже или выберите другой способ оплаты."
|
||||
),
|
||||
text=("❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты."),
|
||||
)
|
||||
return
|
||||
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=main_menu_text, callback_data="profile"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=main_menu_text, callback_data="profile")],
|
||||
]
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(User.language_code).where(User.tg_id == tg_id)
|
||||
)
|
||||
result = await session.execute(select(User.language_code).where(User.tg_id == tg_id))
|
||||
language_code = result.scalar_one_or_none()
|
||||
amount_text = await format_for_user(
|
||||
session,
|
||||
@@ -152,15 +134,10 @@ async def _handle_custom_amount_input_kassai(
|
||||
)
|
||||
text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=message, text=text_out, reply_markup=markup
|
||||
)
|
||||
await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup)
|
||||
except Exception as e:
|
||||
method_label = "Cards" if method_name == "cards" else "SBP"
|
||||
logger.error(
|
||||
f"Ошибка при создании платежа KassaAI {method_label} "
|
||||
f"для пользователя {tg_id}: {e}"
|
||||
)
|
||||
logger.error(f"Ошибка при создании платежа KassaAI {method_label} для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
@@ -175,9 +152,7 @@ async def handle_custom_amount_input_kassai_cards(
|
||||
main_menu_text: str = MAIN_MENU,
|
||||
):
|
||||
"""Функция быстрого потока для KassaI Cards."""
|
||||
await _handle_custom_amount_input_kassai(
|
||||
event, session, "cards", pay_button_text, main_menu_text
|
||||
)
|
||||
await _handle_custom_amount_input_kassai(event, session, "cards", pay_button_text, main_menu_text)
|
||||
|
||||
|
||||
async def handle_custom_amount_input_kassai_sbp(
|
||||
@@ -187,7 +162,4 @@ async def handle_custom_amount_input_kassai_sbp(
|
||||
main_menu_text: str = MAIN_MENU,
|
||||
):
|
||||
"""Функция быстрого потока для KassaI SBP."""
|
||||
await _handle_custom_amount_input_kassai(
|
||||
event, session, "sbp", pay_button_text, main_menu_text
|
||||
)
|
||||
|
||||
await _handle_custom_amount_input_kassai(event, session, "sbp", pay_button_text, main_menu_text)
|
||||
|
||||
@@ -49,9 +49,7 @@ router = Router()
|
||||
|
||||
async def get_user_language(session: AsyncSession, tg_id: int) -> str | None:
|
||||
"""Получает язык пользователя из базы данных."""
|
||||
result = await session.execute(
|
||||
select(User.language_code).where(User.tg_id == tg_id)
|
||||
)
|
||||
result = await session.execute(select(User.language_code).where(User.tg_id == tg_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -65,8 +63,18 @@ class ReplenishBalanceKassaiState(StatesGroup):
|
||||
|
||||
|
||||
KASSAI_METHODS = {
|
||||
"cards": {"enable": PROVIDERS_ENABLED.get("KASSAI_CARDS", False), "method": 36, "button": KASSAI_CARDS, "desc": KASSAI_CARDS_DESCRIPTION},
|
||||
"sbp": {"enable": PROVIDERS_ENABLED.get("KASSAI_SBP", False), "method": 44, "button": KASSAI_SBP, "desc": KASSAI_SBP_DESCRIPTION},
|
||||
"cards": {
|
||||
"enable": PROVIDERS_ENABLED.get("KASSAI_CARDS", False),
|
||||
"method": 36,
|
||||
"button": KASSAI_CARDS,
|
||||
"desc": KASSAI_CARDS_DESCRIPTION,
|
||||
},
|
||||
"sbp": {
|
||||
"enable": PROVIDERS_ENABLED.get("KASSAI_SBP", False),
|
||||
"method": 44,
|
||||
"button": KASSAI_SBP,
|
||||
"desc": KASSAI_SBP_DESCRIPTION,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -94,9 +102,7 @@ async def process_callback_pay_kassai(
|
||||
return
|
||||
|
||||
language_code = await get_user_language(session, tg_id)
|
||||
opts = await payment_options_for_user(
|
||||
session, tg_id, language_code, force_currency="RUB"
|
||||
)
|
||||
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB")
|
||||
builder = build_amounts_keyboard(
|
||||
prefix=f"kassai_{method_name}",
|
||||
pattern="{prefix}_amount|{price}",
|
||||
@@ -141,10 +147,7 @@ async def process_callback_pay_kassai(
|
||||
await state.set_state(ReplenishBalanceKassaiState.choosing_method)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in process_callback_pay_kassai for user "
|
||||
f"{callback_query.message.chat.id}: {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,
|
||||
@@ -168,15 +171,13 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
language_code = await get_user_language(session, tg_id)
|
||||
opts = await payment_options_for_user(
|
||||
session, tg_id, language_code, force_currency="RUB"
|
||||
)
|
||||
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB")
|
||||
builder = build_amounts_keyboard(
|
||||
prefix=f"kassai_{method_name}",
|
||||
pattern="{prefix}_amount|{price}",
|
||||
back_cb="pay_kassai",
|
||||
custom_cb=f"kassai_custom_amount|{method_name}",
|
||||
opts=opts
|
||||
opts=opts,
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -198,7 +199,7 @@ async def process_custom_amount_button(callback_query: types.CallbackQuery, stat
|
||||
|
||||
language_code = await get_user_language(session, callback_query.from_user.id)
|
||||
currency = pick_currency(language_code)
|
||||
|
||||
|
||||
currency_text = "рублях (₽)" if currency == "RUB" else "долларах ($)"
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -229,7 +230,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext,
|
||||
user_amount = int(message.text.strip())
|
||||
if user_amount <= 0:
|
||||
raise ValueError
|
||||
|
||||
|
||||
if method_name == "cards":
|
||||
min_amount = 1 if currency == "USD" else 50
|
||||
currency_symbol = "$" if currency == "USD" else "₽"
|
||||
@@ -260,7 +261,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext,
|
||||
|
||||
if currency == "RUB":
|
||||
amount_rub = user_amount
|
||||
else:
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session_http:
|
||||
amount_rub = int(await to_rub(user_amount, "USD", session=session_http))
|
||||
|
||||
|
||||
@@ -26,18 +26,12 @@ def verify_kassai_signature(data: dict, signature: str) -> bool:
|
||||
"""
|
||||
try:
|
||||
sign_string = (
|
||||
f"{KASSAI_SHOP_ID}:{data.get('AMOUNT', '')}:"
|
||||
f"{KASSAI_SECRET_KEY}:{data.get('MERCHANT_ORDER_ID', '')}"
|
||||
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()
|
||||
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. "
|
||||
f"Expected: {expected_signature}, Got: {signature}"
|
||||
)
|
||||
logger.error(f"KassaAI signature mismatch. Expected: {expected_signature}, Got: {signature}")
|
||||
logger.error(f"Sign string: {sign_string}")
|
||||
else:
|
||||
logger.info("KassaAI webhook: подпись успешно проверена")
|
||||
@@ -64,9 +58,7 @@ async def kassai_webhook(request: web.Request):
|
||||
order_id = data.get("MERCHANT_ORDER_ID")
|
||||
|
||||
if not amount_raw or not order_id:
|
||||
logger.error(
|
||||
"KassaAI webhook: отсутствуют обязательные параметры"
|
||||
)
|
||||
logger.error("KassaAI webhook: отсутствуют обязательные параметры")
|
||||
return web.Response(status=400)
|
||||
|
||||
amount = float(amount_raw)
|
||||
@@ -74,16 +66,10 @@ async def kassai_webhook(request: web.Request):
|
||||
try:
|
||||
tg_id = int(order_id.split("_")[1])
|
||||
except (IndexError, ValueError) as e:
|
||||
logger.error(
|
||||
f"KassaAI webhook: не удалось извлечь tg_id "
|
||||
f"из order_id {order_id}: {e}"
|
||||
)
|
||||
logger.error(f"KassaAI webhook: не удалось извлечь tg_id из order_id {order_id}: {e}")
|
||||
return web.Response(status=400)
|
||||
|
||||
logger.info(
|
||||
f"KassaAI: успешный платёж {order_id} на сумму {amount} RUB "
|
||||
f"для пользователя {tg_id}"
|
||||
)
|
||||
logger.info(f"KassaAI: успешный платёж {order_id} на сумму {amount} RUB для пользователя {tg_id}")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
payment = await get_payment_by_payment_id(session, order_id)
|
||||
@@ -110,8 +96,7 @@ async def kassai_webhook(request: web.Request):
|
||||
await update_balance(session, tg_id, amount)
|
||||
await send_payment_success_notification(tg_id, amount, session)
|
||||
logger.info(
|
||||
f"KassaAI: платёж {order_id} успешно обработан, "
|
||||
f"баланс пользователя {tg_id} пополнен на {amount} RUB"
|
||||
f"KassaAI: платёж {order_id} успешно обработан, баланс пользователя {tg_id} пополнен на {amount} RUB"
|
||||
)
|
||||
return web.Response(text=KASSAI_WEBHOOK_RESPONSE)
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,7 +13,7 @@ async def payment_options_for_user(
|
||||
tg_id: int,
|
||||
language_code: str | None,
|
||||
*,
|
||||
force_currency: str | None = None,
|
||||
force_currency: str | None = None,
|
||||
) -> list[dict]:
|
||||
items = []
|
||||
for price_rub in RENEWAL_PRICES.values():
|
||||
@@ -22,7 +22,7 @@ async def payment_options_for_user(
|
||||
tg_id,
|
||||
price_rub,
|
||||
language_code,
|
||||
force_currency=force_currency,
|
||||
force_currency=force_currency,
|
||||
)
|
||||
items.append({"text": txt, "callback_data": f"amount|{int(price_rub)}"})
|
||||
return items
|
||||
|
||||
@@ -217,9 +217,7 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
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 += f"Сумма: {formatted_amount}\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
|
||||
history_text += "</blockquote>"
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
@@ -788,7 +788,9 @@ async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, sess
|
||||
|
||||
if has_device_option and selected_devices is not None:
|
||||
pack_devices_val = int(selected_devices)
|
||||
if pack_devices_val <= 0 or (new_device_limit_effective is not None and new_device_limit_effective <= 0):
|
||||
if pack_devices_val <= 0 or (
|
||||
new_device_limit_effective is not None and new_device_limit_effective <= 0
|
||||
):
|
||||
new_device_limit_effective = 0
|
||||
else:
|
||||
if new_device_limit_effective is None:
|
||||
|
||||
@@ -244,9 +244,7 @@ async def migrate_between_subgroups(
|
||||
external_squad_uuid: str | None = None,
|
||||
tariff_id: int | None = None,
|
||||
) -> tuple[str, str | None]:
|
||||
target = await filter_cluster_by_subgroup(
|
||||
session, cluster_all, target_subgroup, cluster_id, tariff_id=tariff_id
|
||||
)
|
||||
target = await filter_cluster_by_subgroup(session, cluster_all, target_subgroup, cluster_id, tariff_id=tariff_id)
|
||||
xui_tgt, remna_tgt = split_by_panel(target)
|
||||
|
||||
old_set = await filter_cluster_by_subgroup(session, cluster_all, old_subgroup, cluster_id)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
+9
-14
@@ -70,10 +70,7 @@ class UserMiddleware(BaseMiddleware):
|
||||
async def _touch_user(self, tg_id: int, session: AsyncSession) -> dict | None:
|
||||
now = datetime.utcnow()
|
||||
res = await session.execute(
|
||||
update(DbUser)
|
||||
.where(DbUser.tg_id == tg_id)
|
||||
.values(updated_at=now)
|
||||
.returning(DbUser)
|
||||
update(DbUser).where(DbUser.tg_id == tg_id).values(updated_at=now).returning(DbUser)
|
||||
)
|
||||
obj = res.scalar_one_or_none()
|
||||
if obj is None:
|
||||
@@ -84,13 +81,11 @@ class UserMiddleware(BaseMiddleware):
|
||||
return d
|
||||
|
||||
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",
|
||||
]
|
||||
)
|
||||
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",
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user