Merge branch 'dev' into feature/multicurrency
This commit is contained in:
@@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||
|
||||
@@ -255,6 +255,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
|
||||
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
||||
|
||||
|
||||
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
|
||||
payload = _serialize_user(user)
|
||||
user_id = int(user.user_id)
|
||||
payload["avatar_url"] = (
|
||||
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
|
||||
if user_id in avatar_keys
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||
|
||||
@@ -587,7 +598,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
avatar_user_ids = [target_id]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
@@ -635,11 +651,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
if target_id in avatar_keys
|
||||
else None
|
||||
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||
serialized_inviter = (
|
||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||
)
|
||||
|
||||
return _ok(
|
||||
@@ -655,11 +669,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
"inviter": serialized_inviter,
|
||||
"invitees_total": int(invitees_total or 0),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_referrals_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found", "User not found")
|
||||
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
target_id,
|
||||
limit=page_size,
|
||||
offset=page * page_size,
|
||||
)
|
||||
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
|
||||
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
|
||||
if inviter is not None
|
||||
else None,
|
||||
"invitees": [
|
||||
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
|
||||
],
|
||||
"total": int(invitees_total or 0),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
@@ -1031,7 +1088,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
||||
|
||||
|
||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
@@ -89,6 +89,31 @@ async def _find_user_by_admin_input(
|
||||
return None
|
||||
|
||||
|
||||
def _admin_user_reference_label(
|
||||
user: Optional[User], fallback_user_id: Optional[int] = None
|
||||
) -> str:
|
||||
if user is None:
|
||||
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
|
||||
|
||||
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
|
||||
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
if full_name:
|
||||
label = full_name
|
||||
elif user.username:
|
||||
label = username_for_display(user.username, with_at=True)
|
||||
elif user.email:
|
||||
label = user.email
|
||||
else:
|
||||
label = f"ID {user.user_id}"
|
||||
return f"{label} · ID {user.user_id}"
|
||||
|
||||
|
||||
def _admin_user_button_label(user: User) -> str:
|
||||
label = _admin_user_reference_label(user)
|
||||
return label[:64]
|
||||
|
||||
|
||||
async def users_list_handler(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
@@ -196,7 +221,13 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
|
||||
)
|
||||
|
||||
# Row 3b: Premium override + traffic grant
|
||||
# Row 3b: Referral details
|
||||
builder.button(
|
||||
text=_(key="admin_user_invitees_button"),
|
||||
callback_data=f"user_action:invitees:{user_id}:0",
|
||||
)
|
||||
|
||||
# Row 4: Premium override + traffic grant
|
||||
builder.button(
|
||||
text=_(key="admin_user_premium_override_button"),
|
||||
callback_data=f"user_action:premium_override:{user_id}",
|
||||
@@ -230,9 +261,9 @@ def get_user_card_keyboard(
|
||||
|
||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||
if quick_links_count == 0:
|
||||
builder.adjust(2, 2, 2, 2, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
@@ -315,7 +346,11 @@ async def format_user_card(
|
||||
|
||||
# Referral info
|
||||
if user.referred_by_id:
|
||||
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
|
||||
referrer = await user_dal.get_referrer_for_user(session, user)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_invited_by_label')} "
|
||||
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
|
||||
)
|
||||
|
||||
# Panel info
|
||||
if user.panel_user_uuid:
|
||||
@@ -622,6 +657,12 @@ async def user_action_handler(
|
||||
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||
elif action == "view_logs":
|
||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||
elif action == "invitees":
|
||||
try:
|
||||
page = max(0, int(parts[3])) if len(parts) > 3 else 0
|
||||
except (TypeError, ValueError):
|
||||
page = 0
|
||||
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
|
||||
elif action == "refresh":
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
||||
@@ -1059,6 +1100,120 @@ async def handle_view_user_logs(
|
||||
await callback.answer(_("admin_user_logs_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_view_user_invitees(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
page: int = 0,
|
||||
):
|
||||
"""Show users invited by the selected account."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
page_size = 10
|
||||
safe_page = max(0, int(page or 0))
|
||||
|
||||
try:
|
||||
total = await user_dal.count_users_referred_by(session, user.user_id)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
if safe_page >= total_pages:
|
||||
safe_page = total_pages - 1
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
user.user_id,
|
||||
limit=page_size,
|
||||
offset=safe_page * page_size,
|
||||
)
|
||||
|
||||
header = _(
|
||||
"admin_user_invitees_message_title",
|
||||
user=hcode(_admin_user_reference_label(user)),
|
||||
total=total,
|
||||
current=safe_page + 1,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
if total <= 0:
|
||||
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
|
||||
else:
|
||||
lines = []
|
||||
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
|
||||
registered = (
|
||||
invitee.registration_date.strftime("%Y-%m-%d")
|
||||
if invitee.registration_date
|
||||
else ""
|
||||
)
|
||||
suffix = (
|
||||
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
|
||||
)
|
||||
lines.append(
|
||||
_(
|
||||
"admin_user_invitee_item",
|
||||
index=index,
|
||||
user=hcode(_admin_user_reference_label(invitee)),
|
||||
suffix=suffix,
|
||||
)
|
||||
)
|
||||
invitees_text = "\n".join([header, "", *lines])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for invitee in invitees:
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_admin_user_button_label(invitee),
|
||||
callback_data=f"user_action:refresh:{invitee.user_id}",
|
||||
)
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if safe_page > 0:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
|
||||
)
|
||||
)
|
||||
if safe_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error viewing invitees for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_refresh_user_card(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -1967,7 +2122,7 @@ async def user_card_from_list_handler(
|
||||
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
|
||||
)
|
||||
quick_links_width = 2 if user.referred_by_id else 1
|
||||
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
|
||||
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
|
||||
|
||||
# Format user card
|
||||
try:
|
||||
|
||||
@@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.dal import security_dal, user_dal
|
||||
from db.models import EmailVerificationCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -222,6 +223,28 @@ class EmailAuthService:
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
)
|
||||
resolved_target_user_id = target_user_id
|
||||
if resolved_target_user_id is None:
|
||||
try:
|
||||
existing_user = await user_dal.get_user_by_email(session, normalized_email)
|
||||
resolved_target_user_id = (
|
||||
int(existing_user.user_id) if existing_user is not None else None
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to resolve email auth target user for audit log: %s",
|
||||
normalized_email,
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=resolved_target_user_id,
|
||||
event_type="email_login_code_sent"
|
||||
if purpose == "login"
|
||||
else "email_verification_code_sent",
|
||||
channel="email",
|
||||
recipient=normalized_email,
|
||||
content=f"purpose={purpose} magic_link={bool(magic_link)}",
|
||||
)
|
||||
return EmailCodeRequestResult(ok=True)
|
||||
|
||||
async def verify_code(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import message_log_dal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _clean_piece(value: Optional[object]) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
async def log_user_message_delivery(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
target_user_id: Optional[int],
|
||||
event_type: str,
|
||||
channel: str,
|
||||
content: str,
|
||||
recipient: Optional[str] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""Add a best-effort user log entry for important outbound messages."""
|
||||
clean_event = _clean_piece(event_type)
|
||||
clean_channel = _clean_piece(channel)
|
||||
if not clean_event or not clean_channel:
|
||||
return
|
||||
|
||||
parts = [f"channel={clean_channel}"]
|
||||
clean_recipient = _clean_piece(recipient)
|
||||
if clean_recipient:
|
||||
parts.append(f"recipient={clean_recipient}")
|
||||
clean_content = _clean_piece(content)
|
||||
if clean_content:
|
||||
parts.append(clean_content)
|
||||
|
||||
try:
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": None,
|
||||
"event_type": clean_event,
|
||||
"content": " | ".join(parts)[:4000],
|
||||
"is_admin_event": False,
|
||||
"target_user_id": int(target_user_id) if target_user_id is not None else None,
|
||||
"timestamp": timestamp or datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to add outbound message audit log for user %s event %s",
|
||||
target_user_id,
|
||||
clean_event,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_subscription_lifecycle_notification
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
@@ -188,6 +189,18 @@ class SubscriptionLifecycleNotificationService:
|
||||
self._channel_key(stage.key, "telegram"),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=getattr(sub, "user_id", None),
|
||||
event_type="telegram_subscription_notification_sent",
|
||||
channel="telegram",
|
||||
recipient=str(chat_id),
|
||||
content=(
|
||||
f"stage={stage.key} message_key={stage.message_key} "
|
||||
f"subscription_id={getattr(sub, 'subscription_id', '')}"
|
||||
),
|
||||
timestamp=sent_at,
|
||||
)
|
||||
if user:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
@@ -253,6 +266,18 @@ class SubscriptionLifecycleNotificationService:
|
||||
self._channel_key(stage.key, "email"),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=getattr(sub, "user_id", None),
|
||||
event_type="email_subscription_notification_sent",
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
content=(
|
||||
f"stage={stage.key} message_key={stage.message_key} "
|
||||
f"subscription_id={getattr(sub, 'subscription_id', '')}"
|
||||
),
|
||||
timestamp=sent_at,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _already_sent(
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload, sessionmaker
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_lifecycle_notifications import (
|
||||
SubscriptionLifecycleNotificationService,
|
||||
@@ -331,6 +332,17 @@ class SubscriptionNotificationWorker:
|
||||
parse_mode="HTML",
|
||||
)
|
||||
telegram_sent = True
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(telegram_chat_id),
|
||||
content=(
|
||||
"kind=trial warning_key=trial_traffic_depleted "
|
||||
f"used_bytes={used} limit_bytes={limit}"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
status = telegram_notification_status_from_error(exc)
|
||||
if status and user and user_id:
|
||||
@@ -355,6 +367,12 @@ class SubscriptionNotificationWorker:
|
||||
subject_key="email_trial_traffic_depleted_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||
session=session,
|
||||
audit_event_type="email_traffic_warning_sent",
|
||||
audit_content=(
|
||||
"kind=trial warning_key=trial_traffic_depleted "
|
||||
f"used_bytes={used} limit_bytes={limit}"
|
||||
),
|
||||
)
|
||||
return {"telegram": telegram_sent, "email": email_sent}
|
||||
|
||||
|
||||
@@ -112,15 +112,14 @@ class TariffMixin:
|
||||
regular_unlimited_override: bool,
|
||||
traffic_used_bytes: int,
|
||||
) -> int:
|
||||
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
|
||||
"""Numeric cap sent to the panel; Remnawave treats ``0`` as unlimited."""
|
||||
floor = (
|
||||
int(tier_baseline_bytes or 0)
|
||||
+ max(0, int(topup_balance_bytes or 0))
|
||||
+ max(0, int(regular_bonus_bytes or 0))
|
||||
)
|
||||
if regular_unlimited_override:
|
||||
used = max(0, int(traffic_used_bytes or 0))
|
||||
return max(floor, used + 512 * (1024**3), 1024**5)
|
||||
return 0
|
||||
return floor
|
||||
|
||||
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
@@ -111,6 +112,8 @@ class TariffTrafficWorker:
|
||||
subject_key: str,
|
||||
message_text: str,
|
||||
kind: str,
|
||||
warning_key: str,
|
||||
audit_content: str,
|
||||
) -> None:
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -131,6 +134,9 @@ class TariffTrafficWorker:
|
||||
if kind == "premium"
|
||||
else "email_traffic_warning_regular_cta"
|
||||
),
|
||||
session=session,
|
||||
audit_event_type="email_traffic_warning_sent",
|
||||
audit_content=f"{audit_content} subject_key={subject_key} warning_key={warning_key}",
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -602,6 +608,15 @@ class TariffTrafficWorker:
|
||||
**usage,
|
||||
)
|
||||
subject_key = "email_traffic_warning_regular_depleted_subject"
|
||||
warning_key = (
|
||||
"traffic_warning_regular_almost"
|
||||
if level < 100
|
||||
else "traffic_warning_regular_depleted"
|
||||
)
|
||||
audit_content = (
|
||||
f"kind=regular warning_key={warning_key} level={level} "
|
||||
f"used_bytes={used_val} limit_bytes={limit_val}"
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||
@@ -611,6 +626,14 @@ class TariffTrafficWorker:
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=sub.user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(sub.user_id),
|
||||
content=audit_content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||
await self._send_traffic_warning_email(
|
||||
@@ -619,6 +642,8 @@ class TariffTrafficWorker:
|
||||
subject_key=subject_key,
|
||||
message_text=text,
|
||||
kind="regular",
|
||||
warning_key=warning_key,
|
||||
audit_content=audit_content,
|
||||
)
|
||||
if ratio >= 1.0 and not sub.is_throttled:
|
||||
logging.info(
|
||||
@@ -1066,6 +1091,11 @@ class TariffTrafficWorker:
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
warning_key = "traffic_warning_premium_depleted"
|
||||
audit_content = (
|
||||
f"kind=premium warning_key={warning_key} "
|
||||
f"used_bytes={used_val} limit_bytes={limit_val}"
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
@@ -1075,6 +1105,14 @@ class TariffTrafficWorker:
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=sub.user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(sub.user_id),
|
||||
content=audit_content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
||||
@@ -1085,6 +1123,8 @@ class TariffTrafficWorker:
|
||||
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||
message_text=text,
|
||||
kind="premium",
|
||||
warning_key=warning_key,
|
||||
audit_content=audit_content,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1134,6 +1174,11 @@ class TariffTrafficWorker:
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
warning_key = "traffic_warning_premium_almost"
|
||||
audit_content = (
|
||||
f"kind=premium warning_key={warning_key} level={int(level)} "
|
||||
f"used_bytes={used_val} limit_bytes={limit_val}"
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
@@ -1143,6 +1188,14 @@ class TariffTrafficWorker:
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=sub.user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(sub.user_id),
|
||||
content=audit_content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send premium traffic warning to user %s", sub.user_id
|
||||
@@ -1153,6 +1206,8 @@ class TariffTrafficWorker:
|
||||
subject_key="email_traffic_warning_premium_almost_subject",
|
||||
message_text=text,
|
||||
kind="premium",
|
||||
warning_key=warning_key,
|
||||
audit_content=audit_content,
|
||||
)
|
||||
|
||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_user_notification
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -34,6 +37,9 @@ async def send_user_notification_email(
|
||||
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||
heading_key: Optional[str] = None,
|
||||
intro_key: Optional[str] = None,
|
||||
session: Optional[AsyncSession] = None,
|
||||
audit_event_type: Optional[str] = None,
|
||||
audit_content: Optional[str] = None,
|
||||
) -> bool:
|
||||
if not getattr(settings, "email_auth_configured", False):
|
||||
return False
|
||||
@@ -78,6 +84,15 @@ async def send_user_notification_email(
|
||||
email=recipient,
|
||||
content=content,
|
||||
)
|
||||
if session is not None and audit_event_type:
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=getattr(user, "user_id", None),
|
||||
event_type=audit_event_type,
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
content=audit_content or f"subject_key={subject_key}",
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||
|
||||
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_referrer_for_user(session: AsyncSession, user: User) -> Optional[User]:
|
||||
referred_by_id = getattr(user, "referred_by_id", None)
|
||||
if referred_by_id is None:
|
||||
return None
|
||||
return await get_user_by_id(session, int(referred_by_id))
|
||||
|
||||
|
||||
async def get_users_referred_by(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[User]:
|
||||
safe_limit = max(1, min(500, int(limit or 50)))
|
||||
safe_offset = max(0, int(offset or 0))
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.registration_date.desc().nullslast(), User.user_id.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users_referred_by(session: AsyncSession, user_id: int) -> int:
|
||||
stmt = select(func.count(User.user_id)).where(User.referred_by_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip("@").lower()
|
||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||
|
||||
Reference in New Issue
Block a user