Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| a42bc9b281 | |||
| 5bc5567ab1 | |||
| d88ca980ec | |||
| 0ef4f55304 | |||
| 5070bb34e8 | |||
| 02d38d7891 | |||
| c46cc85144 | |||
| 071c23dd52 | |||
| 5f3e426750 | |||
| a9eee19c95 | |||
| 552a8ff8d8 | |||
| bec78beb25 | |||
| a6561a4788 | |||
| c49acc956f | |||
| 4c40b5b370 | |||
| 7c1a142653 | |||
| a161e2f904 | |||
| ad260d9fe0 | |||
| 3fd3bce2cf | |||
| ad6522f547 | |||
| 9ea533a864 | |||
| 61bb8fcafd | |||
| ff5bba3fc5 | |||
| cc1c8bacb4 | |||
| b707b7995b | |||
| a076dfb550 | |||
| 91ac90c2ae | |||
| b12544d3ea | |||
| 38018514dc | |||
| 924d6bc09c | |||
| 1021c2cdcd | |||
| fa01819674 | |||
| eeed2d6369 | |||
| a194be0843 | |||
| aa1cd3829c | |||
| 6c2c25d2cc | |||
| 0b61c7fe48 | |||
| b6745508da | |||
| f5391c3159 | |||
| 9a81932d2b | |||
| 8b50fde9aa | |||
| 8b924df64f | |||
| 7102c50f52 | |||
| e4c65ca220 | |||
| 557dbf3ebe | |||
| 462f7a99b9 | |||
| c68c4e5984 | |||
| f00a051bb3 | |||
| b94e3edf80 | |||
| 51ca3e42b7 | |||
| 943e9a86aa | |||
| c4da591731 | |||
| 287a43ba65 | |||
| 070321230b | |||
| 8886d0dea2 | |||
| d3819c492f | |||
| 3cbb9ef024 | |||
| 41633af763 | |||
| ccd9ab02c5 | |||
| d0a9cfe6a9 | |||
| 333a3c5901 | |||
| 0de6418bca | |||
| e9b98b837a | |||
| 97be4afbff | |||
| 9ca24efe43 | |||
| 116c8453bb | |||
| 4e7438b9f9 | |||
| c4794db1dd | |||
| 1ffb8a5b85 | |||
| 7ab1a7b88d | |||
| e3f932afe4 | |||
| 5ca2f62854 |
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -20,6 +20,5 @@ jobs:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
release-type: python
|
||||
extra-files: |
|
||||
pyproject.toml
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.5.0"
|
||||
".": "3.7.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
|
||||
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
|
||||
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
|
||||
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
|
||||
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
|
||||
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
|
||||
|
||||
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
|
||||
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
|
||||
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
|
||||
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
|
||||
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
|
||||
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
|
||||
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
|
||||
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
|
||||
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
|
||||
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
|
||||
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
|
||||
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
|
||||
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
|
||||
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
|
||||
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
|
||||
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
|
||||
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
|
||||
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
|
||||
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
|
||||
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
|
||||
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
|
||||
|
||||
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
|
||||
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
|
||||
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
|
||||
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
|
||||
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
|
||||
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
|
||||
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
|
||||
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
|
||||
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
|
||||
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
|
||||
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
|
||||
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
|
||||
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
|
||||
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
|
||||
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
|
||||
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
|
||||
|
||||
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.4.0"
|
||||
ARG VERSION="v3.7.2" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
# --- Typed dicts for provider API responses ---
|
||||
|
||||
|
||||
class OAuthProviderConfig(TypedDict):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
enabled: bool
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class GoogleUserInfoResponse(TypedDict, total=False):
|
||||
sub: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
given_name: str
|
||||
family_name: str
|
||||
picture: str
|
||||
name: str
|
||||
|
||||
|
||||
class YandexUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
login: str
|
||||
default_email: str
|
||||
emails: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
default_avatar_id: str
|
||||
|
||||
|
||||
class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
username: str
|
||||
global_name: str
|
||||
email: str
|
||||
verified: bool
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""Normalized user info from OAuth provider."""
|
||||
|
||||
provider: str
|
||||
provider_id: str
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Base class for OAuth 2.0 providers."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
"""Fetch user info from the provider."""
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProvider):
|
||||
name = 'google'
|
||||
display_name = 'Google'
|
||||
|
||||
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
json={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: GoogleUserInfoResponse = response.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='google',
|
||||
provider_id=str(data['sub']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('email_verified', False),
|
||||
first_name=data.get('given_name'),
|
||||
last_name=data.get('family_name'),
|
||||
avatar_url=data.get('picture'),
|
||||
)
|
||||
|
||||
|
||||
class YandexProvider(OAuthProvider):
|
||||
name = 'yandex'
|
||||
display_name = 'Yandex'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'login:info login:email',
|
||||
'state': state,
|
||||
'force_confirm': 'yes',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={'format': 'json'},
|
||||
headers={'Authorization': f'OAuth {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: YandexUserInfoResponse = response.json()
|
||||
|
||||
default_email = data.get('default_email')
|
||||
emails = data.get('emails', [])
|
||||
email = default_email or (emails[0] if emails else None)
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='yandex',
|
||||
provider_id=str(data['id']),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
username=data.get('login'),
|
||||
avatar_url=(
|
||||
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
|
||||
if data.get('default_avatar_id')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProvider):
|
||||
name = 'discord'
|
||||
display_name = 'Discord'
|
||||
|
||||
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify email',
|
||||
'state': state,
|
||||
'prompt': 'consent',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: DiscordUserInfoResponse = response.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get('avatar'):
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='discord',
|
||||
provider_id=str(data['id']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('verified', False),
|
||||
first_name=data.get('global_name') or data.get('username'),
|
||||
username=data.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
# --- Provider factory ---
|
||||
|
||||
_PROVIDERS: dict[str, type[OAuthProvider]] = {
|
||||
'google': GoogleProvider,
|
||||
'yandex': YandexProvider,
|
||||
'discord': DiscordProvider,
|
||||
'vk': VKProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get an OAuth provider instance if enabled.
|
||||
|
||||
Returns None if the provider is not enabled or not found.
|
||||
"""
|
||||
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
|
||||
config = providers_config.get(name)
|
||||
if not config or not config['enabled']:
|
||||
return None
|
||||
|
||||
provider_class = _PROVIDERS.get(name)
|
||||
if not provider_class:
|
||||
return None
|
||||
|
||||
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
|
||||
|
||||
return provider_class(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .auth import router as auth_router
|
||||
@@ -26,6 +27,7 @@ from .contests import router as contests_router
|
||||
from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
@@ -45,6 +47,7 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
@@ -83,6 +86,7 @@ router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -388,7 +388,7 @@ async def update_existing_tariff(
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ async def get_all_tickets(
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -355,6 +356,10 @@ async def get_all_tickets(
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
count_query = count_query.where(Ticket.user_id == user_id)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
|
||||
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
|
||||
|
||||
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
|
||||
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
"""Get subscription status via actual_status property."""
|
||||
return sub.actual_status
|
||||
|
||||
|
||||
def _validate_period(period: int) -> None:
|
||||
if period not in _ALLOWED_PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_traffic(
|
||||
start_str: str, end_str: str, user_uuids: list[str]
|
||||
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
|
||||
"""Aggregate per-user traffic across all nodes for a given date range.
|
||||
|
||||
Uses legacy per-node endpoint to fetch all users' traffic per node —
|
||||
O(nodes) API calls instead of O(users). The legacy endpoint returns
|
||||
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
|
||||
without userUuid).
|
||||
|
||||
Returns (user_traffic, nodes_info) where:
|
||||
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
|
||||
nodes_info = [TrafficNodeInfo, ...]
|
||||
"""
|
||||
cache_key = (start_str, end_str)
|
||||
|
||||
# Quick check without lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
# Acquire lock for the slow path
|
||||
async with _cache_lock:
|
||||
# Re-check after acquiring lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {}, []
|
||||
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
|
||||
async def fetch_node_users(node):
|
||||
async with semaphore:
|
||||
try:
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
|
||||
return node.uuid, None
|
||||
|
||||
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
|
||||
|
||||
nodes_info: list[TrafficNodeInfo] = [
|
||||
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
|
||||
]
|
||||
nodes_info.sort(key=lambda n: n.node_name)
|
||||
|
||||
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
|
||||
user_traffic: dict[str, dict[str, int]] = {}
|
||||
for node_uuid, entries in results:
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
uid = entry.get('userUuid', '')
|
||||
total = int(entry.get('total', 0))
|
||||
if uid and total > 0 and uid in user_uuids_set:
|
||||
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
|
||||
|
||||
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
|
||||
|
||||
# Evict expired entries to prevent unbounded growth
|
||||
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
|
||||
for k in expired:
|
||||
del _traffic_cache[k]
|
||||
|
||||
return user_traffic, nodes_info
|
||||
|
||||
|
||||
def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
"""Compute ISO date-time range from period days.
|
||||
|
||||
Truncates to 5-minute intervals for stable cache keys.
|
||||
"""
|
||||
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
|
||||
start_dt = end_dt - timedelta(days=period_days)
|
||||
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
user_traffic: dict[str, dict[str, int]],
|
||||
user_map: dict[str, User],
|
||||
nodes_info: list[TrafficNodeInfo],
|
||||
search: str = '',
|
||||
sort_by: str = 'total_bytes',
|
||||
sort_desc: bool = True,
|
||||
tariff_filter: set[str] | None = None,
|
||||
status_filter: set[str] | None = None,
|
||||
node_filter: set[str] | None = None,
|
||||
) -> list[UserTrafficItem]:
|
||||
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
|
||||
items: list[UserTrafficItem] = []
|
||||
search_lower = search.lower().strip()
|
||||
|
||||
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
|
||||
for uuid in all_uuids:
|
||||
user = user_map.get(uuid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
traffic = user_traffic.get(uuid, {})
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
|
||||
if search_lower:
|
||||
if search_lower not in (full_name or '').lower() and search_lower not in (username or '').lower():
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
|
||||
if status_filter is not None:
|
||||
if (subscription_status or '') not in status_filter:
|
||||
continue
|
||||
|
||||
# Apply node filter: keep only selected nodes, recalculate total
|
||||
if node_filter is not None:
|
||||
traffic = {k: v for k, v in traffic.items() if k in node_filter}
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by the requested field; node columns use 'node_<uuid>' prefix
|
||||
if sort_by.startswith('node_'):
|
||||
node_uuid = sort_by[5:]
|
||||
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
|
||||
elif sort_by in ('full_name', 'tariff_name'):
|
||||
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
|
||||
else:
|
||||
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query('', max_length=100),
|
||||
sort_by: str = Query('total_bytes', max_length=100),
|
||||
sort_desc: bool = Query(True),
|
||||
tariffs: str = Query('', max_length=500),
|
||||
statuses: str = Query('', max_length=500),
|
||||
nodes: str = Query('', max_length=2000),
|
||||
start_date: str = Query('', max_length=10),
|
||||
end_date: str = Query('', max_length=10),
|
||||
):
|
||||
"""Get paginated per-user traffic usage by node."""
|
||||
# Determine date range: custom dates or period-based
|
||||
if start_date.strip() and end_date.strip():
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
effective_period = (end_dt - start_dt).days or 1
|
||||
else:
|
||||
_validate_period(period)
|
||||
start_str, end_str = _compute_date_range(period)
|
||||
effective_period = period
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
tariff_filter: set[str] | None = None
|
||||
if tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
|
||||
|
||||
# Parse status filter
|
||||
status_filter: set[str] | None = None
|
||||
if statuses.strip():
|
||||
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
|
||||
|
||||
# Parse node filter
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if nodes.strip():
|
||||
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + 'node_<uuid>' for dynamic node columns
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, sort_by, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
return TrafficUsageResponse(
|
||||
items=paginated,
|
||||
nodes=nodes_info,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
period_days=effective_period,
|
||||
available_tariffs=available_tariffs,
|
||||
available_statuses=available_statuses,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
if not admin.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin has no Telegram ID configured',
|
||||
)
|
||||
|
||||
# Determine date range: custom dates or period-based
|
||||
if request.start_date and request.end_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
|
||||
tzinfo=UTC, hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
period_label = f'{request.start_date}_{request.end_date}'
|
||||
else:
|
||||
_validate_period(request.period)
|
||||
start_str, end_str = _compute_date_range(request.period)
|
||||
period_label = f'{request.period}d'
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
if request.tariffs and request.tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
|
||||
|
||||
status_filter: set[str] | None = None
|
||||
if request.statuses and request.statuses.strip():
|
||||
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
|
||||
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if request.nodes and request.nodes.strip():
|
||||
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic,
|
||||
user_map,
|
||||
nodes_info,
|
||||
sort_by='total_bytes',
|
||||
sort_desc=True,
|
||||
tariff_filter=tariff_filter,
|
||||
status_filter=status_filter,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
|
||||
# Determine which nodes to include in CSV columns
|
||||
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
|
||||
|
||||
# Compute period days for risk calculation
|
||||
if request.start_date and request.end_date:
|
||||
period_days = max((end_dt - start_dt).days, 1)
|
||||
else:
|
||||
period_days = request.period
|
||||
|
||||
total_thr = request.total_threshold_gb or 0
|
||||
node_thr = request.node_threshold_gb or 0
|
||||
has_risk = total_thr > 0 or node_thr > 0
|
||||
|
||||
# Build CSV rows
|
||||
rows: list[dict] = []
|
||||
for item in items:
|
||||
row: dict = {
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Devices': item.device_limit,
|
||||
}
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
|
||||
|
||||
if has_risk:
|
||||
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
row['Total GB/day'] = round(daily_total, 4)
|
||||
|
||||
total_ratio = daily_total / total_thr if total_thr > 0 else 0
|
||||
|
||||
max_node_ratio = 0.0
|
||||
worst_node_daily = 0.0
|
||||
for node_bytes in item.node_traffic.values():
|
||||
if node_bytes > 0 and node_thr > 0:
|
||||
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
ratio = daily_node / node_thr
|
||||
if ratio > max_node_ratio:
|
||||
max_node_ratio = ratio
|
||||
worst_node_daily = daily_node
|
||||
|
||||
ratio = max(total_ratio, max_node_ratio)
|
||||
if ratio < 0.5:
|
||||
risk_level = 'low'
|
||||
elif ratio < 0.8:
|
||||
risk_level = 'medium'
|
||||
elif ratio < 1.2:
|
||||
risk_level = 'high'
|
||||
else:
|
||||
risk_level = 'critical'
|
||||
|
||||
row['Risk Level'] = risk_level
|
||||
row['Risk Ratio'] = round(ratio, 3)
|
||||
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
document=BufferedInputFile(csv_bytes, filename=filename),
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send CSV report. Please try again later.',
|
||||
)
|
||||
|
||||
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
extend_subscription,
|
||||
)
|
||||
@@ -68,6 +69,9 @@ from ..schemas.users import (
|
||||
UserAvailableTariffsResponse,
|
||||
UserDetailResponse,
|
||||
UserListItem,
|
||||
UserNodeUsageItem,
|
||||
UserNodeUsageResponse,
|
||||
UserPanelInfoResponse,
|
||||
UserPromoGroupInfo,
|
||||
UserReferralInfo,
|
||||
UsersListResponse,
|
||||
@@ -525,6 +529,14 @@ async def get_user_detail(
|
||||
for t in transactions
|
||||
]
|
||||
|
||||
# Get campaign info
|
||||
campaign_name = None
|
||||
campaign_id = None
|
||||
campaign_reg = await get_campaign_registration_by_user(db, user.id)
|
||||
if campaign_reg and campaign_reg.campaign:
|
||||
campaign_name = campaign_reg.campaign.name
|
||||
campaign_id = campaign_reg.campaign.id
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -550,6 +562,8 @@ async def get_user_detail(
|
||||
used_promocodes=user.used_promocodes,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0,
|
||||
campaign_name=campaign_name,
|
||||
campaign_id=campaign_id,
|
||||
restriction_topup=user.restriction_topup,
|
||||
restriction_subscription=user.restriction_subscription,
|
||||
restriction_reason=user.restriction_reason,
|
||||
@@ -577,6 +591,156 @@ async def get_user_by_telegram(
|
||||
return await get_user_detail(user.id, admin, db)
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
|
||||
async def get_user_panel_info(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured or not user.telegram_id:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if not panel_users:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
|
||||
last_node_name = None
|
||||
last_node_uuid = None
|
||||
if panel_user.user_traffic and panel_user.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid = panel_user.user_traffic.last_connected_node_uuid
|
||||
try:
|
||||
accessible = await api.get_user_accessible_nodes(panel_user.uuid)
|
||||
for node in accessible:
|
||||
if node.uuid == last_node_uuid:
|
||||
last_node_name = node.node_name
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(f'Failed to resolve node name for user {user_id}')
|
||||
|
||||
return UserPanelInfoResponse(
|
||||
found=True,
|
||||
trojan_password=panel_user.trojan_password,
|
||||
vless_uuid=panel_user.vless_uuid,
|
||||
ss_password=panel_user.ss_password,
|
||||
subscription_url=panel_user.subscription_url,
|
||||
happ_link=panel_user.happ_link,
|
||||
used_traffic_bytes=panel_user.used_traffic_bytes,
|
||||
lifetime_used_traffic_bytes=panel_user.lifetime_used_traffic_bytes,
|
||||
traffic_limit_bytes=panel_user.traffic_limit_bytes,
|
||||
first_connected_at=panel_user.first_connected_at,
|
||||
online_at=panel_user.online_at,
|
||||
last_connected_node_uuid=last_node_uuid,
|
||||
last_connected_node_name=last_node_name,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting panel info for user {user_id}: {e}')
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
|
||||
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
|
||||
async def get_user_node_usage(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=30)
|
||||
start_str = start_date.strftime('%Y-%m-%d')
|
||||
end_str = end_date.strftime('%Y-%m-%d')
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Get user's accessible nodes (1 API call)
|
||||
accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid)
|
||||
|
||||
# Get user bandwidth stats (1 API call)
|
||||
# Response: {categories: [dates], series: [{uuid, name, countryCode, total, data: [daily]}, ...]}
|
||||
stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str)
|
||||
|
||||
categories: list[str] = []
|
||||
series_map: dict[str, dict] = {}
|
||||
if isinstance(stats, dict):
|
||||
categories = stats.get('categories', [])
|
||||
for s in stats.get('series', []):
|
||||
series_map[s['uuid']] = {
|
||||
'name': s.get('name', ''),
|
||||
'country_code': s.get('countryCode', ''),
|
||||
'total': int(s.get('total', 0)),
|
||||
'daily': [int(v) for v in s.get('data', [])],
|
||||
}
|
||||
|
||||
# Build items: accessible nodes + any extra from stats
|
||||
items = []
|
||||
seen_uuids: set[str] = set()
|
||||
for node in accessible_nodes:
|
||||
seen_uuids.add(node.uuid)
|
||||
sr = series_map.get(node.uuid)
|
||||
items.append(
|
||||
UserNodeUsageItem(
|
||||
node_uuid=node.uuid,
|
||||
node_name=sr['name'] if sr else node.node_name,
|
||||
country_code=sr['country_code'] if sr else node.country_code,
|
||||
total_bytes=sr['total'] if sr else 0,
|
||||
daily_bytes=sr['daily'] if sr else [],
|
||||
)
|
||||
)
|
||||
for nid, sr in series_map.items():
|
||||
if nid not in seen_uuids:
|
||||
items.append(
|
||||
UserNodeUsageItem(
|
||||
node_uuid=nid,
|
||||
node_name=sr['name'],
|
||||
country_code=sr['country_code'],
|
||||
total_bytes=sr['total'],
|
||||
daily_bytes=sr['daily'],
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(key=lambda x: x.total_bytes, reverse=True)
|
||||
return UserNodeUsageResponse(items=items, categories=categories)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting node usage for user {user_id}: {e}')
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
|
||||
# === Balance Management ===
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
@@ -385,6 +386,13 @@ async def register_email(
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
"""
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing_user.scalar_one_or_none():
|
||||
@@ -478,6 +486,13 @@ async def register_email_standalone(
|
||||
)
|
||||
logger.info(f'Test email registration: {request.email}')
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing.scalar_one_or_none():
|
||||
@@ -971,6 +986,13 @@ async def request_email_change(
|
||||
detail='New email is the same as current email',
|
||||
)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.new_email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if new email is already taken
|
||||
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -23,7 +23,6 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -341,13 +340,11 @@ async def create_topup(
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
yookassa_service = YooKassaService()
|
||||
payment_service = PaymentService()
|
||||
yookassa_metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
'user_username': user.username or '',
|
||||
'amount_kopeks': str(request.amount_kopeks),
|
||||
'type': 'balance_topup',
|
||||
'purpose': 'balance_topup',
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
@@ -358,25 +355,25 @@ async def create_topup(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
)
|
||||
if option == 'sbp':
|
||||
# Create SBP payment with QR code
|
||||
result = await yookassa_service.create_sbp_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
else:
|
||||
# Default: card payment
|
||||
result = await yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
|
||||
if result and not result.get('error'):
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('id')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..auth.oauth_providers import (
|
||||
OAuthUserInfo,
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
return auth_response
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class OAuthProviderInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthProvidersResponse(BaseModel):
|
||||
providers: list[OAuthProviderInfo]
|
||||
|
||||
|
||||
class OAuthAuthorizeResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get('/providers', response_model=OAuthProvidersResponse)
|
||||
async def get_oauth_providers():
|
||||
"""Get list of enabled OAuth providers."""
|
||||
providers_config = settings.get_oauth_providers_config()
|
||||
providers = [
|
||||
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
|
||||
for name, cfg in providers_config.items()
|
||||
if cfg['enabled']
|
||||
]
|
||||
return OAuthProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# 4. Fetch user info from provider
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via %s for existing user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
email=user_info.email if user_info.email_verified else None,
|
||||
email_verified=user_info.email_verified,
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
@@ -1070,6 +1070,19 @@ async def get_trial_info(
|
||||
"""Get trial subscription info and availability."""
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
|
||||
return TrialInfoResponse(
|
||||
is_available=False,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
requires_payment=bool(settings.TRIAL_PAYMENT_ENABLED),
|
||||
price_kopeks=0,
|
||||
price_rubles=0,
|
||||
reason_unavailable='Trial is not available for your account type',
|
||||
)
|
||||
|
||||
duration_days = settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
@@ -1148,6 +1161,13 @@ async def activate_trial(
|
||||
"""Activate trial subscription."""
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Trial is not available for your account type',
|
||||
)
|
||||
|
||||
# Check if user already has an active subscription
|
||||
if user.subscription:
|
||||
now = datetime.utcnow()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Schemas for admin traffic usage."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TrafficNodeInfo(BaseModel):
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
|
||||
|
||||
class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
node_traffic: dict[str, int] # {node_uuid: total_bytes}
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class TrafficUsageResponse(BaseModel):
|
||||
items: list[UserTrafficItem]
|
||||
nodes: list[TrafficNodeInfo]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
period_days: int
|
||||
available_tariffs: list[str]
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
tariffs: str | None = None
|
||||
statuses: str | None = None
|
||||
nodes: str | None = None
|
||||
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
|
||||
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
|
||||
|
||||
|
||||
class ExportCsvResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@@ -189,9 +189,58 @@ class UserDetailResponse(BaseModel):
|
||||
promo_offer_discount_source: str | None = None
|
||||
promo_offer_discount_expires_at: datetime | None = None
|
||||
|
||||
# Campaign
|
||||
campaign_name: str | None = None
|
||||
campaign_id: int | None = None
|
||||
|
||||
# Recent transactions
|
||||
recent_transactions: list[UserTransactionItem] = []
|
||||
|
||||
# Remnawave UUID
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
class UserPanelInfoResponse(BaseModel):
|
||||
"""Panel info for user from Remnawave."""
|
||||
|
||||
found: bool = False
|
||||
trojan_password: str | None = None
|
||||
vless_uuid: str | None = None
|
||||
ss_password: str | None = None
|
||||
subscription_url: str | None = None
|
||||
happ_link: str | None = None
|
||||
used_traffic_bytes: int = 0
|
||||
lifetime_used_traffic_bytes: int = 0
|
||||
traffic_limit_bytes: int = 0
|
||||
first_connected_at: datetime | None = None
|
||||
online_at: datetime | None = None
|
||||
last_connected_node_uuid: str | None = None
|
||||
last_connected_node_name: str | None = None
|
||||
|
||||
|
||||
# === Node Usage ===
|
||||
|
||||
|
||||
class UserNodeUsageItem(BaseModel):
|
||||
"""Per-node traffic usage item."""
|
||||
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str = ''
|
||||
total_bytes: int
|
||||
daily_bytes: list[int] = []
|
||||
|
||||
|
||||
class UserNodeUsageResponse(BaseModel):
|
||||
"""Node usage response with 30-day daily breakdown."""
|
||||
|
||||
items: list[UserNodeUsageItem]
|
||||
categories: list[str] = []
|
||||
period_days: int = 30
|
||||
|
||||
|
||||
# === User Actions ===
|
||||
|
||||
|
||||
+68
-2
@@ -112,6 +112,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: str | None = None
|
||||
TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
@@ -236,6 +237,8 @@ class Settings(BaseSettings):
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -695,6 +698,23 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
|
||||
# OAuth 2.0 provider settings for cabinet
|
||||
OAUTH_GOOGLE_CLIENT_ID: str = ''
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: str = ''
|
||||
OAUTH_GOOGLE_ENABLED: bool = False
|
||||
|
||||
OAUTH_YANDEX_CLIENT_ID: str = ''
|
||||
OAUTH_YANDEX_CLIENT_SECRET: str = ''
|
||||
OAUTH_YANDEX_ENABLED: bool = False
|
||||
|
||||
OAUTH_DISCORD_CLIENT_ID: str = ''
|
||||
OAUTH_DISCORD_CLIENT_SECRET: str = ''
|
||||
OAUTH_DISCORD_ENABLED: bool = False
|
||||
|
||||
OAUTH_VK_CLIENT_ID: str = ''
|
||||
OAUTH_VK_CLIENT_SECRET: str = ''
|
||||
OAUTH_VK_ENABLED: bool = False
|
||||
|
||||
# SMTP settings for cabinet email
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_PORT: int = 587
|
||||
@@ -1031,8 +1051,9 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-')
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
@@ -1309,6 +1330,17 @@ class Settings(BaseSettings):
|
||||
def get_trial_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG')
|
||||
|
||||
def is_trial_disabled_for_user(self, auth_type: str | None) -> bool:
|
||||
disabled_for = self.TRIAL_DISABLED_FOR
|
||||
if disabled_for == 'all':
|
||||
return True
|
||||
# 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.)
|
||||
if disabled_for == 'email' and auth_type not in (None, 'telegram'):
|
||||
return True
|
||||
if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
@@ -2515,6 +2547,40 @@ class Settings(BaseSettings):
|
||||
return self.SMTP_FROM_EMAIL
|
||||
return self.SMTP_USER
|
||||
|
||||
# OAuth helpers
|
||||
def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]:
|
||||
"""Return config for all OAuth providers (enabled or not)."""
|
||||
return {
|
||||
'google': {
|
||||
'client_id': self.OAUTH_GOOGLE_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_GOOGLE_ENABLED,
|
||||
'display_name': 'Google',
|
||||
},
|
||||
'yandex': {
|
||||
'client_id': self.OAUTH_YANDEX_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_YANDEX_ENABLED,
|
||||
'display_name': 'Yandex',
|
||||
},
|
||||
'discord': {
|
||||
'client_id': self.OAUTH_DISCORD_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_DISCORD_ENABLED,
|
||||
'display_name': 'Discord',
|
||||
},
|
||||
'vk': {
|
||||
'client_id': self.OAUTH_VK_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_VK_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_VK_ENABLED,
|
||||
'display_name': 'VK',
|
||||
},
|
||||
}
|
||||
|
||||
def get_enabled_oauth_provider_names(self) -> list[str]:
|
||||
"""Return list of enabled OAuth provider names."""
|
||||
return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']]
|
||||
|
||||
# Ban System helpers
|
||||
def is_ban_system_enabled(self) -> bool:
|
||||
return bool(self.BAN_SYSTEM_ENABLED)
|
||||
|
||||
+26
-23
@@ -492,8 +492,8 @@ async def reorder_tariffs(
|
||||
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
"""
|
||||
Синхронизирует дефолтный тариф из конфига (.env) в БД.
|
||||
Создаёт тариф "Стандартный" если в БД нет тарифов.
|
||||
Обновляет цены существующего тарифа если он есть.
|
||||
Создаёт тариф "Стандартный" только если в БД нет тарифов.
|
||||
Существующий тариф НЕ перезаписывается — админ управляет им через кабинет.
|
||||
|
||||
Returns:
|
||||
Tariff или None если не требуется синхронизация
|
||||
@@ -519,13 +519,11 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
existing_tariff = result.scalar_one_or_none()
|
||||
|
||||
if existing_tariff:
|
||||
# Обновляем цены существующего тарифа
|
||||
existing_tariff.period_prices = period_prices
|
||||
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
await db.commit()
|
||||
await db.refresh(existing_tariff)
|
||||
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
|
||||
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
|
||||
# Админ управляет тарифом через кабинет, синхронизация не нужна.
|
||||
logger.info(
|
||||
"Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id
|
||||
)
|
||||
return existing_tariff
|
||||
|
||||
if tariff_count == 0:
|
||||
@@ -571,21 +569,26 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
)
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
|
||||
else:
|
||||
if not tariff:
|
||||
logger.info('Активные тарифы не найдены, используются цены из .env')
|
||||
return
|
||||
|
||||
if not tariff.period_prices:
|
||||
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
|
||||
return
|
||||
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка загрузки периодов из БД: %s', e)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import and_, case, func, nullslast, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -1235,3 +1235,100 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Email change cancelled for user {user.id}')
|
||||
|
||||
|
||||
# --- OAuth provider functions ---
|
||||
|
||||
_OAUTH_PROVIDER_COLUMNS = {
|
||||
'google': 'google_id',
|
||||
'yandex': 'yandex_id',
|
||||
'discord': 'discord_id',
|
||||
'vk': 'vk_id',
|
||||
}
|
||||
|
||||
|
||||
async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None:
|
||||
"""Find a user by OAuth provider ID."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return None
|
||||
column = getattr(User, column_name)
|
||||
# VK uses BigInteger, so convert
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
result = await db.execute(select(User).where(column == value))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None:
|
||||
"""Link an OAuth provider ID to an existing user."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
setattr(user, column_name, value)
|
||||
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}')
|
||||
|
||||
|
||||
async def create_user_by_oauth(
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
email: str | None = None,
|
||||
email_verified: bool = False,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
|
||||
user = User(
|
||||
telegram_id=None,
|
||||
auth_type=provider,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
password_hash=None,
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if column_name:
|
||||
setattr(user, column_name, provider_value)
|
||||
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}')
|
||||
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
|
||||
await event_emitter.emit(
|
||||
'user.created',
|
||||
{
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'auth_type': provider,
|
||||
'first_name': user.first_name,
|
||||
'referral_code': user.referral_code,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit user.created event: %s', error)
|
||||
|
||||
return user
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +25,7 @@ async def create_yookassa_payment(
|
||||
payment_method_type: str | None = None,
|
||||
yookassa_created_at: datetime | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> YooKassaPayment:
|
||||
) -> YooKassaPayment | None:
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
@@ -40,7 +41,17 @@ async def create_yookassa_payment(
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
|
||||
@@ -995,6 +995,11 @@ class User(Base):
|
||||
email_change_new = Column(String(255), nullable=True) # New email pending verification
|
||||
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
|
||||
email_change_expires = Column(DateTime, nullable=True) # Code expiration
|
||||
# OAuth provider IDs
|
||||
google_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
yandex_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
@@ -1055,6 +1060,11 @@ class User(Base):
|
||||
"""Пользователь зарегистрирован через email (без Telegram)."""
|
||||
return self.auth_type == 'email' and self.telegram_id is None
|
||||
|
||||
@property
|
||||
def is_web_user(self) -> bool:
|
||||
"""Пользователь без Telegram (email, OAuth и т.д.)."""
|
||||
return self.telegram_id is None
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
|
||||
@@ -5094,6 +5094,58 @@ async def add_transaction_receipt_columns() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_oauth_provider_columns() -> bool:
|
||||
"""Добавить колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) в users."""
|
||||
try:
|
||||
google_exists = await check_column_exists('users', 'google_id')
|
||||
yandex_exists = await check_column_exists('users', 'yandex_id')
|
||||
discord_exists = await check_column_exists('users', 'discord_id')
|
||||
vk_exists = await check_column_exists('users', 'vk_id')
|
||||
|
||||
if google_exists and yandex_exists and discord_exists and vk_exists:
|
||||
logger.info('Колонки OAuth провайдеров уже существуют в users')
|
||||
return True
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if not google_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN google_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка google_id в users')
|
||||
|
||||
if not yandex_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN yandex_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка yandex_id в users')
|
||||
|
||||
if not discord_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN discord_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка discord_id в users')
|
||||
|
||||
if not vk_exists:
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id BIGINT'))
|
||||
else:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id INTEGER'))
|
||||
logger.info('✅ Добавлена колонка vk_id в users')
|
||||
|
||||
# Создаём уникальные индексы
|
||||
for col in ('google_id', 'yandex_id', 'discord_id', 'vk_id'):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
if db_type in ('postgresql', 'sqlite'):
|
||||
await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS uq_users_{col} ON users ({col})'))
|
||||
else:
|
||||
await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})'))
|
||||
except Exception as idx_error:
|
||||
logger.warning(f'Индекс uq_users_{col} возможно уже существует: {idx_error}')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'❌ Ошибка добавления колонок OAuth провайдеров в users: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def create_withdrawal_requests_table() -> bool:
|
||||
"""Создаёт таблицу для заявок на вывод реферального баланса."""
|
||||
try:
|
||||
@@ -7045,6 +7097,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с миграцией transaction_id_cp')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК OAUTH ПРОВАЙДЕРОВ ===')
|
||||
oauth_columns_ready = await add_oauth_provider_columns()
|
||||
if oauth_columns_ready:
|
||||
logger.info('✅ Колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) готовы')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
|
||||
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
|
||||
@@ -7157,6 +7216,10 @@ async def check_migration_status():
|
||||
'campaign_tariff_duration_days_column': False,
|
||||
'campaign_registration_tariff_id_column': False,
|
||||
'campaign_registration_tariff_duration_days_column': False,
|
||||
'users_google_id_column': False,
|
||||
'users_yandex_id_column': False,
|
||||
'users_discord_id_column': False,
|
||||
'users_vk_id_column': False,
|
||||
}
|
||||
|
||||
status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -7288,6 +7351,12 @@ async def check_migration_status():
|
||||
'transactions', 'receipt_created_at'
|
||||
)
|
||||
|
||||
# Колонки OAuth провайдеров в users
|
||||
status['users_google_id_column'] = await check_column_exists('users', 'google_id')
|
||||
status['users_yandex_id_column'] = await check_column_exists('users', 'yandex_id')
|
||||
status['users_discord_id_column'] = await check_column_exists('users', 'discord_id')
|
||||
status['users_vk_id_column'] = await check_column_exists('users', 'vk_id')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(
|
||||
text("""
|
||||
@@ -7358,6 +7427,10 @@ async def check_migration_status():
|
||||
'subscription_temporary_access_table': 'Таблица subscription_temporary_access',
|
||||
'transactions_receipt_uuid_column': 'Колонка receipt_uuid в transactions',
|
||||
'transactions_receipt_created_at_column': 'Колонка receipt_created_at в transactions',
|
||||
'users_google_id_column': 'Колонка google_id в users',
|
||||
'users_yandex_id_column': 'Колонка yandex_id в users',
|
||||
'users_discord_id_column': 'Колонка discord_id в users',
|
||||
'users_vk_id_column': 'Колонка vk_id в users',
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
+78
-19
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -366,32 +367,63 @@ class RemnaWaveAPI:
|
||||
raise RemnaWaveAPIError('Session not initialized. Use async context manager.')
|
||||
|
||||
url = f'{self.base_url}{endpoint}'
|
||||
max_retries = 3
|
||||
base_delay = 1.0
|
||||
|
||||
try:
|
||||
kwargs = {'url': url, 'params': params}
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
kwargs = {'url': url, 'params': params}
|
||||
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
if response.status == 429 and attempt < max_retries:
|
||||
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
|
||||
logger.warning(
|
||||
'Rate limited (429) on %s %s, retry %d/%d after %.1fs',
|
||||
method,
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
return response_data
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f'Request failed: {e}')
|
||||
raise RemnaWaveAPIError(f'Request failed: {e!s}')
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
if attempt < max_retries:
|
||||
delay = base_delay * (2**attempt)
|
||||
logger.warning(
|
||||
'Request failed on %s %s: %s, retry %d/%d after %.1fs',
|
||||
method,
|
||||
endpoint,
|
||||
e,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
logger.error(f'Request failed: {e}')
|
||||
raise RemnaWaveAPIError(f'Request failed: {e!s}')
|
||||
|
||||
raise RemnaWaveAPIError(f'Max retries exceeded for {method} {endpoint}')
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
@@ -564,6 +596,33 @@ class RemnaWaveAPI:
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_accessible_nodes(self, uuid: str) -> list[RemnaWaveAccessibleNode]:
|
||||
"""Получает список доступных нод для пользователя"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}/accessible-nodes')
|
||||
nodes_data = response.get('response', {}).get('activeNodes', [])
|
||||
result = []
|
||||
for node in nodes_data:
|
||||
# Collect inbounds from activeSquads
|
||||
inbounds: list[str] = []
|
||||
for squad in node.get('activeSquads', []):
|
||||
inbounds.extend(squad.get('activeInbounds', []))
|
||||
result.append(
|
||||
RemnaWaveAccessibleNode(
|
||||
uuid=node['uuid'],
|
||||
node_name=node['nodeName'],
|
||||
country_code=node['countryCode'],
|
||||
config_profile_uuid=node.get('configProfileUuid', ''),
|
||||
config_profile_name=node.get('configProfileName', ''),
|
||||
active_inbounds=inbounds,
|
||||
)
|
||||
)
|
||||
return result
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
@@ -1045,10 +1045,11 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
|
||||
return
|
||||
|
||||
if not getattr(user, 'telegram_id', None):
|
||||
logger.error(
|
||||
'Cannot notify ticket #%s user without telegram_id (username=%s)',
|
||||
logger.warning(
|
||||
'Cannot notify ticket #%s user without telegram_id (username=%s, auth_type=%s)',
|
||||
ticket.id,
|
||||
getattr(user, 'username', None),
|
||||
getattr(user, 'auth_type', None),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -776,12 +776,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
|
||||
try:
|
||||
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
|
||||
except:
|
||||
pass
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e):
|
||||
pass # Сообщение уже содержит нужный текст
|
||||
else:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
|
||||
|
||||
logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}')
|
||||
|
||||
|
||||
@@ -468,6 +468,10 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
|
||||
country_uuid = callback.data.split('_')[1]
|
||||
data = await state.get_data()
|
||||
|
||||
if 'period_days' not in data:
|
||||
await callback.answer('❌ Данные подписки устарели. Начните оформление заново.', show_alert=True)
|
||||
return
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
if country_uuid in selected_countries:
|
||||
selected_countries.remove(country_uuid)
|
||||
|
||||
@@ -24,6 +24,10 @@ async def _prepare_subscription_summary(
|
||||
texts,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
summary_data = dict(data)
|
||||
|
||||
if 'period_days' not in summary_data:
|
||||
raise KeyError('period_days missing from subscription data — FSM state likely expired')
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = calculate_months_from_days(summary_data['period_days'])
|
||||
|
||||
@@ -560,6 +560,15 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
|
||||
await callback.message.edit_text(
|
||||
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
@@ -752,6 +761,15 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
|
||||
await callback.message.edit_text(
|
||||
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
@@ -1384,6 +1402,11 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
prepared_cart_data = dict(cart_data)
|
||||
|
||||
if 'period_days' not in prepared_cart_data:
|
||||
await callback.answer('❌ Корзина повреждена. Оформите подписку заново.', show_alert=True)
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
try:
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
@@ -80,6 +80,9 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
return
|
||||
|
||||
"""Обработать ввод заголовка тикета"""
|
||||
if not message.text:
|
||||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||||
return
|
||||
title = message.text.strip()
|
||||
|
||||
data_prompt = await state.get_data()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
@@ -27,6 +28,9 @@ class BlacklistService:
|
||||
interval_hours = self.get_blacklist_update_interval_hours()
|
||||
self.update_interval = timedelta(hours=interval_hours)
|
||||
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
|
||||
# Кэш результатов проверки: {telegram_id: (is_blacklisted, reason, timestamp)}
|
||||
self._check_cache: dict[int, tuple[bool, str | None, float]] = {}
|
||||
self._cache_ttl = 300 # 5 минут
|
||||
|
||||
def is_blacklist_check_enabled(self) -> bool:
|
||||
"""Проверяет, включена ли проверка черного списка"""
|
||||
@@ -117,6 +121,7 @@ class BlacklistService:
|
||||
|
||||
self.blacklist_data = blacklist_data
|
||||
self.last_update = datetime.utcnow()
|
||||
self._check_cache.clear()
|
||||
logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей')
|
||||
return True
|
||||
|
||||
@@ -141,9 +146,17 @@ class BlacklistService:
|
||||
if not self.is_blacklist_check_enabled():
|
||||
return False, None
|
||||
|
||||
# Проверяем кэш
|
||||
now = time.monotonic()
|
||||
cached = self._check_cache.get(telegram_id)
|
||||
if cached is not None:
|
||||
is_bl, reason, ts = cached
|
||||
if now - ts < self._cache_ttl:
|
||||
return is_bl, reason
|
||||
|
||||
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
|
||||
if self.should_ignore_admins() and self.is_admin(telegram_id):
|
||||
logger.info(f'Пользователь {telegram_id} является администратором, игнорируем проверку черного списка')
|
||||
self._check_cache[telegram_id] = (False, None, now)
|
||||
return False, None
|
||||
|
||||
# Если черный список пуст или устарел, обновляем его
|
||||
@@ -156,6 +169,7 @@ class BlacklistService:
|
||||
for bl_id, bl_username, bl_reason in self.blacklist_data:
|
||||
if bl_id == telegram_id:
|
||||
logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}')
|
||||
self._check_cache[telegram_id] = (True, bl_reason, now)
|
||||
return True, bl_reason
|
||||
|
||||
# Проверяем по username, если он передан
|
||||
@@ -166,8 +180,10 @@ class BlacklistService:
|
||||
logger.info(
|
||||
f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}'
|
||||
)
|
||||
self._check_cache[telegram_id] = (True, bl_reason, now)
|
||||
return True, bl_reason
|
||||
|
||||
self._check_cache[telegram_id] = (False, None, now)
|
||||
return False, None
|
||||
|
||||
async def get_all_blacklisted_users(self) -> list[tuple[int, str, str]]:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Service for blocking disposable/temporary email domains."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DisposableEmailService:
|
||||
"""
|
||||
Downloads and caches a list of disposable email domains from GitHub.
|
||||
|
||||
Domains are stored in a frozenset for O(1) thread-safe lookups.
|
||||
The list is refreshed every 24 hours via an asyncio background task.
|
||||
If the download fails, the service falls back to an empty set (no blocking).
|
||||
"""
|
||||
|
||||
DOMAINS_URL = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt'
|
||||
UPDATE_INTERVAL_HOURS = 24
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._domains: frozenset[str] = frozenset()
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._last_updated: datetime | None = None
|
||||
self._domain_count: int = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Load domains and start periodic refresh task."""
|
||||
await self._update_domains()
|
||||
self._task = asyncio.create_task(self._periodic_loop())
|
||||
logger.info('DisposableEmailService started (%d domains loaded)', self._domain_count)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel periodic refresh task."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info('DisposableEmailService stopped')
|
||||
|
||||
async def _update_domains(self) -> None:
|
||||
"""Fetch domains.txt from GitHub and swap the in-memory set."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session, session.get(self.DOMAINS_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
'Failed to fetch disposable domains: HTTP %d',
|
||||
resp.status,
|
||||
)
|
||||
return
|
||||
|
||||
text = await resp.text()
|
||||
|
||||
domains = frozenset(
|
||||
line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith('#')
|
||||
)
|
||||
|
||||
self._domains = domains
|
||||
self._domain_count = len(domains)
|
||||
self._last_updated = datetime.now(UTC)
|
||||
logger.info('Disposable email domains updated: %d domains', self._domain_count)
|
||||
|
||||
except Exception:
|
||||
logger.exception('Error updating disposable email domains')
|
||||
|
||||
async def _periodic_loop(self) -> None:
|
||||
"""Sleep then refresh, repeating forever until cancelled."""
|
||||
while True:
|
||||
await asyncio.sleep(self.UPDATE_INTERVAL_HOURS * 3600)
|
||||
await self._update_domains()
|
||||
|
||||
def is_disposable(self, email: str) -> bool:
|
||||
"""Check if the email uses a disposable domain.
|
||||
|
||||
Returns False when the feature is disabled via settings.
|
||||
"""
|
||||
if not getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True):
|
||||
return False
|
||||
|
||||
if not self._domains:
|
||||
return False
|
||||
|
||||
try:
|
||||
domain = email.rsplit('@', 1)[1].lower()
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
return domain in self._domains
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Return service status for monitoring / health checks."""
|
||||
return {
|
||||
'enabled': getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True),
|
||||
'domain_count': self._domain_count,
|
||||
'last_updated': self._last_updated.isoformat() if self._last_updated else None,
|
||||
'running': self._task is not None and not self._task.done(),
|
||||
}
|
||||
|
||||
|
||||
disposable_email_service = DisposableEmailService()
|
||||
@@ -1328,6 +1328,27 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify user exists before creating FK-linked record
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Webhook YooKassa %s: user_id=%s не найден в БД, пропускаем восстановление платежа',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Webhook YooKassa %s: не удалось проверить user_id=%s: %s',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
amount_info = event_object.get('amount') or {}
|
||||
amount_value = amount_info.get('value')
|
||||
currency = (amount_info.get('currency') or 'RUB').upper()
|
||||
|
||||
@@ -151,19 +151,20 @@ class RemnaWaveService:
|
||||
elif not api_key:
|
||||
self._config_error = 'REMNAWAVE_API_KEY не настроен'
|
||||
|
||||
self.api: RemnaWaveAPI | None
|
||||
if self._config_error:
|
||||
self.api = None
|
||||
else:
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=auth_params.get('secret_key'),
|
||||
username=auth_params.get('username'),
|
||||
password=auth_params.get('password'),
|
||||
caddy_token=auth_params.get('caddy_token'),
|
||||
auth_type=auth_params.get('auth_type') or 'api_key',
|
||||
)
|
||||
# Сохраняем параметры для создания новых экземпляров API клиента
|
||||
# (каждый вызов get_api_client создаёт свой экземпляр, чтобы
|
||||
# параллельные корутины не перезаписывали друг другу aiohttp-сессию)
|
||||
self._api_kwargs: dict | None = None
|
||||
if not self._config_error:
|
||||
self._api_kwargs = {
|
||||
'base_url': base_url,
|
||||
'api_key': api_key,
|
||||
'secret_key': auth_params.get('secret_key'),
|
||||
'username': auth_params.get('username'),
|
||||
'password': auth_params.get('password'),
|
||||
'caddy_token': auth_params.get('caddy_token'),
|
||||
'auth_type': auth_params.get('auth_type') or 'api_key',
|
||||
}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -174,7 +175,7 @@ class RemnaWaveService:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self.is_configured or self.api is None:
|
||||
if not self.is_configured or self._api_kwargs is None:
|
||||
raise RemnaWaveConfigurationError(self._config_error or 'RemnaWave API не настроен')
|
||||
|
||||
def _ensure_user_remnawave_uuid(
|
||||
@@ -228,8 +229,9 @@ class RemnaWaveService:
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
assert self._api_kwargs is not None
|
||||
api = RemnaWaveAPI(**self._api_kwargs)
|
||||
async with api:
|
||||
yield api
|
||||
|
||||
def _now_utc(self) -> datetime:
|
||||
@@ -1439,12 +1441,14 @@ class RemnaWaveService:
|
||||
|
||||
# Используем один API клиент для всех операций сброса HWID
|
||||
hwid_api_client = None
|
||||
hwid_api_cm = None
|
||||
try:
|
||||
hwid_api_client = self.get_api_client()
|
||||
await hwid_api_client.__aenter__()
|
||||
hwid_api_cm = self.get_api_client()
|
||||
hwid_api_client = await hwid_api_cm.__aenter__()
|
||||
except Exception as api_init_error:
|
||||
logger.warning(f'⚠️ Не удалось создать API клиент для сброса HWID: {api_init_error}')
|
||||
hwid_api_client = None
|
||||
hwid_api_cm = None
|
||||
|
||||
try:
|
||||
for telegram_id, db_user in users_to_deactivate:
|
||||
@@ -1565,9 +1569,9 @@ class RemnaWaveService:
|
||||
|
||||
finally:
|
||||
# Закрываем API клиент
|
||||
if hwid_api_client:
|
||||
if hwid_api_cm:
|
||||
try:
|
||||
await hwid_api_client.__aexit__(None, None, None)
|
||||
await hwid_api_cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -465,6 +465,12 @@ class BotConfigurationService:
|
||||
ChoiceOption('ERROR', '❌ Error'),
|
||||
ChoiceOption('CRITICAL', '🔥 Critical'),
|
||||
],
|
||||
'TRIAL_DISABLED_FOR': [
|
||||
ChoiceOption('none', '✅ Включён для всех'),
|
||||
ChoiceOption('email', '📧 Отключён для Email'),
|
||||
ChoiceOption('telegram', '📱 Отключён для Telegram'),
|
||||
ChoiceOption('all', '🚫 Отключён для всех'),
|
||||
],
|
||||
}
|
||||
|
||||
SETTING_HINTS: dict[str, dict[str, str]] = {
|
||||
|
||||
@@ -153,6 +153,26 @@ def github_markdown_to_telegram_html(text: str) -> str:
|
||||
return result.strip()
|
||||
|
||||
|
||||
def _close_open_tags(html: str) -> str:
|
||||
"""Find unclosed HTML tags and append closing tags in reverse order."""
|
||||
open_tags: list[str] = []
|
||||
for match in _HTML_TAG_RE.finditer(html):
|
||||
is_closing = match.group(1) == '/'
|
||||
is_self_closing = match.group(4) == '/'
|
||||
tag_name = match.group(2).lower()
|
||||
if is_self_closing:
|
||||
continue
|
||||
if is_closing:
|
||||
if open_tags and open_tags[-1] == tag_name:
|
||||
open_tags.pop()
|
||||
else:
|
||||
open_tags.append(tag_name)
|
||||
# Close remaining open tags in reverse order
|
||||
for tag in reversed(open_tags):
|
||||
html += f'</{tag}>'
|
||||
return html
|
||||
|
||||
|
||||
def truncate_for_blockquote(
|
||||
description_html: str,
|
||||
*,
|
||||
@@ -191,8 +211,10 @@ def truncate_for_blockquote(
|
||||
if len(description_html) <= available:
|
||||
return description_html
|
||||
|
||||
# Truncate, trying not to break mid-tag
|
||||
truncated = description_html[: available - len(ellipsis)]
|
||||
# Reserve space for ellipsis, then iteratively truncate until
|
||||
# the result (with closing tags) fits within the budget.
|
||||
budget = available - len(ellipsis)
|
||||
truncated = description_html[:budget]
|
||||
|
||||
# If we broke an HTML tag, backtrack to before it
|
||||
last_open = truncated.rfind('<')
|
||||
@@ -200,4 +222,16 @@ def truncate_for_blockquote(
|
||||
if last_open > last_close:
|
||||
truncated = truncated[:last_open]
|
||||
|
||||
return truncated.rstrip() + ellipsis
|
||||
# Close any unclosed HTML tags to avoid Telegram parse errors
|
||||
closed = _close_open_tags(truncated)
|
||||
|
||||
# If closing tags pushed us over budget, trim more text
|
||||
while len(closed) + len(ellipsis) > available and len(truncated) > 0:
|
||||
truncated = truncated[:-20] if len(truncated) > 20 else ''
|
||||
last_open = truncated.rfind('<')
|
||||
last_close = truncated.rfind('>')
|
||||
if last_open > last_close:
|
||||
truncated = truncated[:last_open]
|
||||
closed = _close_open_tags(truncated)
|
||||
|
||||
return closed.rstrip() + ellipsis
|
||||
|
||||
@@ -3047,6 +3047,9 @@ def _is_trial_available_for_user(user: User) -> bool:
|
||||
if settings.TRIAL_DURATION_DAYS <= 0:
|
||||
return False
|
||||
|
||||
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
|
||||
return False
|
||||
|
||||
if getattr(user, 'has_had_paid_subscription', False):
|
||||
return False
|
||||
|
||||
|
||||
@@ -524,8 +524,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
order_id = payload.get('order_id', 'unknown')
|
||||
logger.error('Wata webhook processing failed: order_id=%s', order_id)
|
||||
order_id = payload.get('orderId') or payload.get('order_id') or 'unknown'
|
||||
logger.error('Wata webhook processing failed: order_id=%s, payload=%s', order_id, payload)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.cabinet.routes import router as cabinet_router
|
||||
from app.config import settings
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.webapi.app import create_web_api_app
|
||||
from app.webapi.docs import add_redoc_endpoint
|
||||
@@ -144,6 +145,14 @@ def create_unified_app(
|
||||
else:
|
||||
telegram_processor = None
|
||||
|
||||
@app.on_event('startup')
|
||||
async def start_disposable_email_service() -> None: # pragma: no cover - event hook
|
||||
await disposable_email_service.start()
|
||||
|
||||
@app.on_event('shutdown')
|
||||
async def stop_disposable_email_service() -> None: # pragma: no cover - event hook
|
||||
await disposable_email_service.stop()
|
||||
|
||||
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
|
||||
|
||||
unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'g5b6c7d8e9f0'
|
||||
down_revision: Union[str, None] = 'f4a5b6c7d8e9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('google_id', sa.String(255), nullable=True))
|
||||
op.add_column('users', sa.Column('yandex_id', sa.String(255), nullable=True))
|
||||
op.add_column('users', sa.Column('discord_id', sa.String(255), nullable=True))
|
||||
op.add_column('users', sa.Column('vk_id', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.create_unique_constraint('uq_users_google_id', 'users', ['google_id'])
|
||||
op.create_unique_constraint('uq_users_yandex_id', 'users', ['yandex_id'])
|
||||
op.create_unique_constraint('uq_users_discord_id', 'users', ['discord_id'])
|
||||
op.create_unique_constraint('uq_users_vk_id', 'users', ['vk_id'])
|
||||
|
||||
op.create_index('ix_users_google_id', 'users', ['google_id'])
|
||||
op.create_index('ix_users_yandex_id', 'users', ['yandex_id'])
|
||||
op.create_index('ix_users_discord_id', 'users', ['discord_id'])
|
||||
op.create_index('ix_users_vk_id', 'users', ['vk_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_users_vk_id', table_name='users')
|
||||
op.drop_index('ix_users_discord_id', table_name='users')
|
||||
op.drop_index('ix_users_yandex_id', table_name='users')
|
||||
op.drop_index('ix_users_google_id', table_name='users')
|
||||
|
||||
op.drop_constraint('uq_users_vk_id', 'users', type_='unique')
|
||||
op.drop_constraint('uq_users_discord_id', 'users', type_='unique')
|
||||
op.drop_constraint('uq_users_yandex_id', 'users', type_='unique')
|
||||
op.drop_constraint('uq_users_google_id', 'users', type_='unique')
|
||||
|
||||
op.drop_column('users', 'vk_id')
|
||||
op.drop_column('users', 'discord_id')
|
||||
op.drop_column('users', 'yandex_id')
|
||||
op.drop_column('users', 'google_id')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.5.0"
|
||||
version = "3.7.2"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"include-component-in-tag": false,
|
||||
"extra-files": [
|
||||
"pyproject.toml"
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "Dockerfile",
|
||||
"glob": false
|
||||
}
|
||||
],
|
||||
"changelog-sections": [
|
||||
{ "type": "feat", "section": "New Features" },
|
||||
|
||||
@@ -576,7 +576,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
|
||||
@@ -1150,7 +1149,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.4.0"
|
||||
version = "3.6.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
|
||||
Reference in New Issue
Block a user