bug fixes: silent_mode/ cash register indempotence/ antispam for key_country/ ban improvements

This commit is contained in:
Vladless
2025-09-02 23:38:52 +03:00
parent 4d3d23ac58
commit a10316d215
15 changed files with 277 additions and 86 deletions
+5 -3
View File
@@ -163,7 +163,9 @@ async def get_tracking_source_with_stats(
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=stats.get("registrations", 0),
trials=stats.get("trials", 0),
payments=stats.get("payments", 0),
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
+13
View File
@@ -94,6 +94,16 @@ class BlockedUserResponse(BaseModel):
from_attributes = True
class MonthlyStats(BaseModel):
month: str
registrations: int
trials: int
new_purchases_count: int
new_purchases_amount: float
repeat_purchases_count: int
repeat_purchases_amount: float
class TrackingSourceResponse(BaseModel):
id: int
name: str
@@ -105,6 +115,9 @@ class TrackingSourceResponse(BaseModel):
registrations: int = 0
trials: int = 0
payments: int = 0
total_amount: float = 0.0
monthly: list[MonthlyStats] = []
class Config:
from_attributes = True
+2 -1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from pydantic import BaseModel
@@ -12,6 +11,7 @@ class TariffBase(BaseModel):
device_limit: int | None = None
is_active: bool = True
subgroup_title: str | None = None
sort_order: int | None = None
class TariffResponse(TariffBase):
@@ -32,6 +32,7 @@ class TariffUpdate(BaseModel):
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
sort_order: int | None = None
class Config:
from_attributes = True
+33 -31
View File
@@ -13,6 +13,7 @@ from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from utils.modules_loader import load_modules_from_folder
from database import async_session_maker
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
@@ -52,26 +53,28 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_entry(
event=event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
@@ -93,38 +96,37 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
from handlers.start import start_entry
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_entry(
event=event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
+128 -11
View File
@@ -1,4 +1,4 @@
from sqlalchemy import func, insert, not_, select
from sqlalchemy import func, insert, not_, select, and_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,6 +6,8 @@ from database.models import Payment, TrackingSource, User
from logger import logger
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
try:
stmt = insert(TrackingSource).values(
@@ -70,12 +72,18 @@ async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
source_result = await session.execute(select(TrackingSource.created_at).where(TrackingSource.code == code))
created_at_row = source_result.first()
if not created_at_row:
def _month_key(dt) -> str:
return dt.strftime("%Y-%m")
src_row = await session.execute(
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at)
.where(TrackingSource.code == code)
)
src = src_row.first()
if not src:
return None
created_at = created_at_row[0]
src_name, src_code, created_at = src
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
@@ -95,25 +103,25 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
amount_subq = (
select(func.coalesce(func.sum(Payment.amount), 0))
select(func.coalesce(func.sum(Payment.amount), 0.0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
query = select(
header_q = select(
TrackingSource.name,
TrackingSource.code,
TrackingSource.created_at,
@@ -123,11 +131,119 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
amount_subq.label("total_amount"),
).where(TrackingSource.code == code)
result = await session.execute(query)
row = result.first()
header_res = await session.execute(header_q)
row = header_res.first()
if not row:
return None
payments_base = (
select(
Payment.tg_id.label("tg_id"),
Payment.amount.label("amount"),
Payment.created_at.label("dt"),
)
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.subquery()
)
first_pay = (
select(
payments_base.c.tg_id.label("tg_id"),
func.min(payments_base.c.dt).label("first_dt"),
)
.group_by(payments_base.c.tg_id)
.subquery()
)
month_expr_new = func.date_trunc("month", payments_base.c.dt).label("month")
new_rows = await session.execute(
select(
month_expr_new,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(
first_pay,
and_(
payments_base.c.tg_id == first_pay.c.tg_id,
payments_base.c.dt == first_pay.c.first_dt,
),
)
.group_by(month_expr_new)
.order_by(month_expr_new)
)
new_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in new_rows.all()}
month_expr_rep = func.date_trunc("month", payments_base.c.dt).label("month")
repeat_rows = await session.execute(
select(
month_expr_rep,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(first_pay, payments_base.c.tg_id == first_pay.c.tg_id)
.where(payments_base.c.dt > first_pay.c.first_dt)
.group_by(month_expr_rep)
.order_by(month_expr_rep)
)
repeat_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in repeat_rows.all()}
month_expr_regs = func.date_trunc("month", User.created_at).label("month")
regs_rows = await session.execute(
select(
month_expr_regs,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.created_at >= created_at))
.group_by(month_expr_regs)
.order_by(month_expr_regs)
)
regs_by_month = {r.month: int(r.cnt) for r in regs_rows.all()}
month_expr_trials = func.date_trunc("month", User.created_at).label("month")
trials_rows = await session.execute(
select(
month_expr_trials,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where(
(User.source_code == code)
& (User.trial == 1)
& (User.created_at >= created_at)
)
.group_by(month_expr_trials)
.order_by(month_expr_trials)
)
trials_by_month = {r.month: int(r.cnt) for r in trials_rows.all()}
months = set()
months.update(regs_by_month.keys())
months.update(trials_by_month.keys())
months.update(new_by_month.keys())
months.update(repeat_by_month.keys())
monthly = []
for m in sorted(months):
regs = regs_by_month.get(m, 0)
trls = trials_by_month.get(m, 0)
new_cnt, new_amt = new_by_month.get(m, (0, 0.0))
rep_cnt, rep_amt = repeat_by_month.get(m, (0, 0.0))
monthly.append({
"month": _month_key(m),
"registrations": regs,
"trials": trls,
"new_purchases_count": new_cnt,
"new_purchases_amount": new_amt,
"repeat_purchases_count": rep_cnt,
"repeat_purchases_amount": rep_amt,
})
return {
"name": row.name,
"code": row.code,
@@ -136,4 +252,5 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
"trials": row.trials or 0,
"payments": row.payments or 0,
"total_amount": float(row.total_amount or 0),
"monthly": monthly,
}
+16 -5
View File
@@ -12,7 +12,7 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMar
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Server, Tariff, User
from database.models import Key, Payment, Server, Tariff, User, BlockedUser, ManualBan
from filters.admin import IsAdminFilter
from logger import logger
@@ -234,9 +234,17 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
except Exception as e:
logger.error(f"Ошибка восстановления клавиатуры: {e}")
banned_tg_ids = (
select(BlockedUser.tg_id).union_all(
select(ManualBan.tg_id).where(
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow())
)
)
)
query = None
if send_to == "subscribed":
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
@@ -250,16 +258,17 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id))
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1])).where(~User.tg_id.in_(banned_tg_ids))
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "hotleads":
subquery = select(Key.tg_id)
@@ -268,6 +277,7 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(~User.tg_id.in_(subquery))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "trial":
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
@@ -275,9 +285,10 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
query = (
select(distinct(Key.tg_id))
.where(Key.tariff_id.in_(trial_tariff_subquery))
.where(~Key.tg_id.in_(banned_tg_ids))
)
else:
query = select(distinct(User.tg_id))
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
+13 -1
View File
@@ -203,7 +203,19 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
if extra_blocks:
stats_message += "\n\n" + "\n\n".join([str(b) for b in extra_blocks if b])
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
new_kb = build_stats_kb()
current_text = callback_query.message.html_text or callback_query.message.text or ""
cur_kb = callback_query.message.reply_markup
cur_kb_json = cur_kb.model_dump_json() if cur_kb else None
new_kb_json = new_kb.model_dump_json() if new_kb else None
if current_text == stats_message and cur_kb_json == new_kb_json:
try:
await callback_query.answer()
except Exception:
pass
else:
await callback_query.message.edit_text(text=stats_message, reply_markup=new_kb)
except TelegramBadRequest as e:
if "message is not modified" not in str(e):
+32 -20
View File
@@ -245,12 +245,6 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name – значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
@@ -263,23 +257,41 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(
tg_id,
expiry_time,
selected_country,
state,
session,
callback_query,
old_key_name,
)
fsm_data = await state.get_data()
if fsm_data.get("creating_key"):
try:
await callback_query.answer("⏳ Уже обрабатываю…")
except Exception:
pass
return
await state.update_data(creating_key=True)
try:
await callback_query.answer("Обрабатываю…")
if callback_query.message:
await callback_query.message.edit_reply_markup(reply_markup=None)
except Exception:
pass
try:
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
await finalize_key_creation(
tg_id,
expiry_time,
selected_country,
state,
session,
callback_query,
old_key_name,
)
finally:
fsm_data = await state.get_data()
if fsm_data.get("creating_key"):
await state.update_data(creating_key=False)
async def finalize_key_creation(
+12 -7
View File
@@ -338,7 +338,11 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
await edit_or_send_message(
target_message=callback_query.message,
text="❌ Указанный тариф не найден.",
)
await callback_query.answer()
return
discount_info = await check_hot_lead_discount(session, tg_id)
@@ -346,18 +350,18 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback_query.message.edit_text(
"❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
reply_markup=builder.as_markup()
await edit_or_send_message(
target_message=callback_query.message,
text="❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
reply_markup=builder.as_markup(),
)
await callback_query.answer()
return
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
balance = await get_balance(session, tg_id)
price_rub = tariff["price_rub"]
if balance < price_rub:
required_amount = ceil(price_rub - balance)
@@ -374,7 +378,7 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
module_fast_flow_handlers = load_module_fast_flow_handlers()
flow_handled = False
if USE_NEW_PAYMENT_FLOW in module_fast_flow_handlers:
try:
handler = module_fast_flow_handlers[USE_NEW_PAYMENT_FLOW]
@@ -413,6 +417,7 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
await callback_query.answer()
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(tariff_id=tariff_id)
+12 -3
View File
@@ -49,7 +49,10 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
if method_name:
method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None)
if not method:
await callback_query.message.delete()
try:
await callback_query.message.delete()
except Exception:
pass
await callback_query.message.answer(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
@@ -80,7 +83,10 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
await callback_query.message.delete()
try:
await callback_query.message.delete()
except Exception:
pass
new_msg = await callback_query.message.answer(
target_message=callback_query.message,
text=method["desc"],
@@ -101,7 +107,10 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f'kassai_method|{method["name"]}'))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
await callback_query.message.delete()
try:
await callback_query.message.delete()
except Exception:
pass
new_msg = await callback_query.message.answer(
target_message=callback_query.message,
text="Выберите способ оплаты через KassaAI:",
+1 -1
View File
@@ -255,7 +255,7 @@ async def robokassa_webhook(request: web.Request):
logger.info(f"Processing payment for user {tg_id} with amount {amount}.")
async with async_session_maker() as session:
recent_time = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(minutes=1)
recent_time = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(seconds=10)
result = await session.execute(
select(Payment).where(
+10 -3
View File
@@ -4,6 +4,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
from sqlalchemy import select
from datetime import datetime
from config import DISABLE_DIRECT_START
from database import async_session_maker, check_user_exists
@@ -60,9 +61,15 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
elif start_param.startswith("gift_"):
gift_id = start_param.removeprefix("gift_")
result = await session.execute(select(Gift).where(Gift.id == gift_id))
result = await session.execute(
select(Gift).where(
Gift.gift_id == gift_id,
Gift.is_used.is_(False),
(Gift.expiry_time.is_(None)) | (Gift.expiry_time > datetime.utcnow()),
)
)
if not result.scalar_one_or_none():
logger.info(f"[DirectStartBlocker] Подарок не найден: {gift_id!r}")
logger.info(f"[DirectStartBlocker] Подарок неактивен или не найден: {gift_id!r}")
return
elif start_param.startswith("referral_"):
@@ -92,4 +99,4 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
)
return
return await handler(event, data)
return