Merge branch 'dev' into feature/migration-remnashop

This commit is contained in:
3252a8
2026-06-02 14:48:11 +03:00
26 changed files with 870 additions and 45 deletions
+6 -1
View File
@@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
provider = sub.provider
is_trial = str(provider or "").strip().lower() == "trial"
display_label = "Trial" if is_trial else sub.tariff_key
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
@@ -118,8 +121,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"display_label": display_label,
"is_trial": is_trial,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"provider": provider,
"is_throttled": bool(sub.is_throttled),
}
@@ -745,6 +745,24 @@ def _user_search_condition(query: str):
return or_(*conditions)
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
first_trial_sub = trial_subs[0] if trial_subs else None
latest_trial_sub = trial_subs[-1] if trial_subs else None
first_start = getattr(first_trial_sub, "start_date", None)
latest_start = getattr(latest_trial_sub, "start_date", None)
latest_end = getattr(latest_trial_sub, "end_date", None)
reset_at = getattr(user, "trial_eligibility_reset_at", None)
return {
"used": bool(trial_subs),
"count": len(trial_subs),
"first_activated_at": first_start.isoformat() if first_start else None,
"latest_activated_at": latest_start.isoformat() if latest_start else None,
"latest_end_date": latest_end.isoformat() if latest_end else None,
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
"last_reset_at": reset_at.isoformat() if reset_at else None,
}
async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
.limit(20)
)
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
trial_subs_stmt = (
select(Subscription)
.where(
Subscription.user_id == target_id,
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
)
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
)
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = (
select(Payment)
@@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
serialized_inviter = (
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
)
trial_payload = _serialize_trial_summary(user, trial_subs)
return _ok(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"trial": trial_payload,
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0),
+44 -2
View File
@@ -77,14 +77,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
"int",
"general",
"ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.",
(
"Telegram ID канала для проверки подписки. Если бот видит канал, "
"ссылка кнопки будет получена автоматически."
),
),
SettingField(
"REQUIRED_CHANNEL_LINK",
"string",
"general",
"Ссылка на канал",
"Имя пользователя или invite-link.",
(
"Необязательно: публичный @username или invite-link, "
"если ссылку нельзя получить по ID канала."
),
),
SettingField(
"PANEL_API_URL",
@@ -101,6 +107,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Секретный ключ API панели.",
secret=True,
),
SettingField(
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API total timeout",
"Maximum total time for one Remnawave API request, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API connect timeout",
"Maximum time to get or open a Remnawave API connection, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API socket connect timeout",
"Maximum TCP/TLS connection time for Remnawave API, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API socket read timeout",
"Maximum time to wait for response data from Remnawave API, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_WEBHOOK_SECRET",
"string",
+23
View File
@@ -2,6 +2,7 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from db.dal import message_log_dal
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
@@ -395,6 +396,28 @@ async def activate_trial_route(request: web.Request) -> web.Response:
except Exception:
logger.exception("Failed to send WebApp trial activation notification")
try:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": user_id,
"telegram_username": getattr(db_user, "username", None),
"telegram_first_name": getattr(db_user, "first_name", None),
"event_type": "webapp_trial_activate",
"content": (
f"Trial activated via WebApp for user_id={user_id}; "
f"email={getattr(db_user, 'email', None) or 'N/A'}"
),
"is_admin_event": False,
"target_user_id": user_id,
"timestamp": datetime.now(timezone.utc),
},
)
except Exception:
logger.exception("Failed to add WebApp trial activation audit log")
await session.commit()
try:
from db.dal import ad_dal as _ad_dal
+6 -4
View File
@@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback
from bot.utils.channel_subscription import (
is_required_channel_access_error,
normalize_required_channel_id,
resolve_required_channel_link,
)
from bot.utils.install_links import (
append_install_share_link_text,
@@ -437,11 +438,12 @@ async def ensure_required_channel_subscription(
)
return True
keyboard = (
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
if i18n
else None
channel_link = await resolve_required_channel_link(
bot_instance,
required_channel_id,
settings.REQUIRED_CHANNEL_LINK,
)
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
prompt_text = translate("channel_subscription_required")
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import locale_language_options
from bot.utils.channel_subscription import normalize_required_channel_link
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
@@ -718,10 +719,11 @@ def get_channel_subscription_keyboard(
has_buttons = False
if channel_link:
channel_url = normalize_required_channel_link(channel_link)
if channel_url:
builder.button(
text=_(key="channel_subscription_join_button"),
url=channel_link,
url=channel_url,
)
has_buttons = True
@@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.utils.channel_subscription import normalize_required_channel_id
from bot.utils.channel_subscription import (
normalize_required_channel_id,
resolve_required_channel_link,
)
from config.settings import Settings
from db.dal import user_dal
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
return i18n_instance.gettext(current_lang, key)
return key
bot_instance = data.get("bot") or data.get("bot_instance")
channel_link = await resolve_required_channel_link(
bot_instance,
required_channel_id,
self.settings.REQUIRED_CHANNEL_LINK,
)
keyboard = (
get_channel_subscription_keyboard(
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
)
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
if i18n_instance
else None
)
+94 -11
View File
@@ -22,6 +22,11 @@ class PanelApiService:
_TRANSIENT_STATUS_CODES = (-1, -3)
_SAFE_METHODS = frozenset({"GET", "HEAD"})
_RETRY_BACKOFF_SECONDS = 0.5
_MIN_TIMEOUT_SECONDS = 0.1
_DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0
_DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0
_DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0
def __init__(self, settings: Settings):
self.settings = settings
@@ -70,17 +75,46 @@ class PanelApiService:
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
# Separate connect/read timeouts so a stuck panel does not hold a
# bot worker for the full window; total caps worst-case latency.
timeout = aiohttp.ClientTimeout(
total=15,
connect=3,
sock_connect=3,
sock_read=10,
)
self._session = aiohttp.ClientSession(timeout=timeout)
self._session = aiohttp.ClientSession(timeout=self._client_timeout())
return self._session
@classmethod
def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float:
raw_value = getattr(settings, name, default)
try:
value = float(raw_value)
except (TypeError, ValueError):
return default
if value <= 0:
return default
return max(cls._MIN_TIMEOUT_SECONDS, value)
def _client_timeout(self) -> aiohttp.ClientTimeout:
# Separate connect/read timeouts so a slow panel route has more room,
# while genuinely stuck requests still cannot pin a worker forever.
return aiohttp.ClientTimeout(
total=self._timeout_setting(
self.settings,
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
self._DEFAULT_TOTAL_TIMEOUT_SECONDS,
),
connect=self._timeout_setting(
self.settings,
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
self._DEFAULT_CONNECT_TIMEOUT_SECONDS,
),
sock_connect=self._timeout_setting(
self.settings,
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS,
),
sock_read=self._timeout_setting(
self.settings,
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS,
),
)
async def close_session(self):
if self._session and not self._session.closed:
await self._session.close()
@@ -121,6 +155,15 @@ class PanelApiService:
for attempt in range(max_attempts):
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
if attempt + 1 < max_attempts and self._is_transient_error(result):
logging.warning(
"Retrying transient Panel API request method=%s endpoint=%s "
"attempt=%s/%s status_code=%s",
method.upper(),
endpoint,
attempt + 1,
max_attempts,
result.get("status_code") if isinstance(result, dict) else None,
)
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
continue
return result
@@ -158,8 +201,8 @@ class PanelApiService:
)
except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
started = time.monotonic()
try:
started = time.monotonic()
async with aiohttp_session.request(
method.upper(), url_for_request, headers=headers, **kwargs
) as response:
@@ -228,15 +271,48 @@ class PanelApiService:
return {"error": True, "status_code": response_status, "details": error_details}
except aiohttp.ClientConnectorError as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
except aiohttp.ServerTimeoutError as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.warning("Panel API timeout to %s: %s", url_for_request, e)
return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"}
except aiohttp.ClientError as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.exception("Panel API ClientError to %s.", url_for_request)
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
except asyncio.TimeoutError:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.error(f"Panel API request to {url_for_request} timed out.")
return {"error": True, "status_code": -3, "message": "Request timed out"}
except Exception as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.error(
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
)
@@ -885,7 +961,14 @@ class PanelApiService:
await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
if squads is not None:
return squads
stale_squads = self._squads_cache.get_stale("list")
if stale_squads is not None:
logging.warning("Using stale internal squads cache after panel fetch failed.")
return stale_squads
return None
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
+66 -1
View File
@@ -1,4 +1,9 @@
from typing import Optional
import logging
import re
from typing import Any, Optional
_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE)
_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$")
def normalize_required_channel_id(value: object) -> Optional[int]:
@@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]:
return -int(f"100{raw_abs}")
def normalize_required_channel_link(value: object) -> Optional[str]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
if _TELEGRAM_LINK_RE.match(raw):
return raw
raw = raw.lstrip("@").strip()
if not raw or re.search(r"\s", raw):
return None
if raw.startswith(("t.me/", "telegram.me/")):
return f"https://{raw}"
if raw.startswith(("+", "joinchat/", "c/")):
return f"https://t.me/{raw}"
if _TELEGRAM_USERNAME_RE.fullmatch(raw):
return f"https://t.me/{raw}"
return None
def _required_channel_link_from_chat(chat: Any) -> Optional[str]:
username = str(getattr(chat, "username", "") or "").strip().lstrip("@")
if username:
return f"https://t.me/{username}"
invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None))
if invite_link:
return invite_link
return None
async def resolve_required_channel_link(
bot: Any,
required_channel_id: Optional[int],
configured_link: object,
) -> Optional[str]:
if bot is not None and required_channel_id:
try:
chat = await bot.get_chat(required_channel_id)
resolved_link = _required_channel_link_from_chat(chat)
if resolved_link:
return resolved_link
except Exception as error:
logging.warning(
"Failed to resolve required channel link from chat %s: %s",
required_channel_id,
error,
)
return normalize_required_channel_link(configured_link)
def is_required_channel_access_error(error: BaseException) -> bool:
message = str(error).lower()
configuration_markers = (
+9
View File
@@ -29,6 +29,15 @@ class AsyncTTLCache:
return None
return value
def get_stale(self, key: str) -> Optional[Any]:
entry = self._data.get(key)
if entry is None:
return None
_, value = entry
if not self._is_cacheable(value):
return None
return value
@staticmethod
def _is_cacheable(value: Any) -> bool:
if value is None:
+4
View File
@@ -100,6 +100,10 @@ class Settings(BaseSettings):
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25)
PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15)
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)