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)) regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) 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 { return {
"subscription_id": int(sub.subscription_id), "subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid, "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_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited), "premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key, "tariff_key": sub.tariff_key,
"display_label": display_label,
"is_trial": is_trial,
"auto_renew_enabled": bool(sub.auto_renew_enabled), "auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider, "provider": provider,
"is_throttled": bool(sub.is_throttled), "is_throttled": bool(sub.is_throttled),
} }
@@ -745,6 +745,24 @@ def _user_search_condition(query: str):
return or_(*conditions) 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: async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request) _require_admin_user_id(request)
target_id = int(request.match_info["user_id"]) 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) .limit(20)
) )
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() 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) total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = ( recent_payments_stmt = (
select(Payment) select(Payment)
@@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
serialized_inviter = ( serialized_inviter = (
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None _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( return _ok(
{ {
"user": serialized_user, "user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None, "active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"trial": trial_payload,
"total_paid": float(total_paid), "total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments], "recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0), "log_count": int(log_count or 0),
+44 -2
View File
@@ -77,14 +77,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
"int", "int",
"general", "general",
"ID обязательного канала", "ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.", (
"Telegram ID канала для проверки подписки. Если бот видит канал, "
"ссылка кнопки будет получена автоматически."
),
), ),
SettingField( SettingField(
"REQUIRED_CHANNEL_LINK", "REQUIRED_CHANNEL_LINK",
"string", "string",
"general", "general",
"Ссылка на канал", "Ссылка на канал",
"Имя пользователя или invite-link.", (
"Необязательно: публичный @username или invite-link, "
"если ссылку нельзя получить по ID канала."
),
), ),
SettingField( SettingField(
"PANEL_API_URL", "PANEL_API_URL",
@@ -101,6 +107,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Секретный ключ API панели.", "Секретный ключ API панели.",
secret=True, 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( SettingField(
"PANEL_WEBHOOK_SECRET", "PANEL_WEBHOOK_SECRET",
"string", "string",
+23
View File
@@ -2,6 +2,7 @@
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches 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]: 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: except Exception:
logger.exception("Failed to send WebApp trial activation notification") 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: try:
from db.dal import ad_dal as _ad_dal 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 ( from bot.utils.channel_subscription import (
is_required_channel_access_error, is_required_channel_access_error,
normalize_required_channel_id, normalize_required_channel_id,
resolve_required_channel_link,
) )
from bot.utils.install_links import ( from bot.utils.install_links import (
append_install_share_link_text, append_install_share_link_text,
@@ -437,11 +438,12 @@ async def ensure_required_channel_subscription(
) )
return True return True
keyboard = ( channel_link = await resolve_required_channel_link(
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK) bot_instance,
if i18n required_channel_id,
else None settings.REQUIRED_CHANNEL_LINK,
) )
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
prompt_text = translate("channel_subscription_required") prompt_text = translate("channel_subscription_required")
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import locale_language_options 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.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings from config.settings import Settings
@@ -718,10 +719,11 @@ def get_channel_subscription_keyboard(
has_buttons = False has_buttons = False
if channel_link: channel_url = normalize_required_channel_link(channel_link)
if channel_url:
builder.button( builder.button(
text=_(key="channel_subscription_join_button"), text=_(key="channel_subscription_join_button"),
url=channel_link, url=channel_url,
) )
has_buttons = True 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.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n 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 config.settings import Settings
from db.dal import user_dal from db.dal import user_dal
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
return i18n_instance.gettext(current_lang, key) return i18n_instance.gettext(current_lang, key)
return 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 = ( keyboard = (
get_channel_subscription_keyboard( get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
)
if i18n_instance if i18n_instance
else None else None
) )
+94 -11
View File
@@ -22,6 +22,11 @@ class PanelApiService:
_TRANSIENT_STATUS_CODES = (-1, -3) _TRANSIENT_STATUS_CODES = (-1, -3)
_SAFE_METHODS = frozenset({"GET", "HEAD"}) _SAFE_METHODS = frozenset({"GET", "HEAD"})
_RETRY_BACKOFF_SECONDS = 0.5 _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): def __init__(self, settings: Settings):
self.settings = settings self.settings = settings
@@ -70,17 +75,46 @@ class PanelApiService:
async def _get_session(self) -> aiohttp.ClientSession: async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
# Separate connect/read timeouts so a stuck panel does not hold a self._session = aiohttp.ClientSession(timeout=self._client_timeout())
# 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)
return self._session 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): async def close_session(self):
if self._session and not self._session.closed: if self._session and not self._session.closed:
await self._session.close() await self._session.close()
@@ -121,6 +155,15 @@ class PanelApiService:
for attempt in range(max_attempts): for attempt in range(max_attempts):
result = await self._request_once(method, endpoint, log_full_response, **kwargs) result = await self._request_once(method, endpoint, log_full_response, **kwargs)
if attempt + 1 < max_attempts and self._is_transient_error(result): 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) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
continue continue
return result return result
@@ -158,8 +201,8 @@ class PanelApiService:
) )
except Exception: except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
started = time.monotonic()
try: try:
started = time.monotonic()
async with aiohttp_session.request( async with aiohttp_session.request(
method.upper(), url_for_request, headers=headers, **kwargs method.upper(), url_for_request, headers=headers, **kwargs
) as response: ) as response:
@@ -228,15 +271,48 @@ class PanelApiService:
return {"error": True, "status_code": response_status, "details": error_details} return {"error": True, "status_code": response_status, "details": error_details}
except aiohttp.ClientConnectorError as e: 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}") logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
return {"error": True, "status_code": -1, "message": f"Connection error: {str(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: 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) logging.exception("Panel API ClientError to %s.", url_for_request)
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
except asyncio.TimeoutError: 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.") logging.error(f"Panel API request to {url_for_request} timed out.")
return {"error": True, "status_code": -3, "message": "Request timed out"} return {"error": True, "status_code": -3, "message": "Request timed out"}
except Exception as e: 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( logging.error(
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True 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}") await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: 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]]]: async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
response_data = await self._request("GET", "/internal-squads", log_full_response=False) 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]: 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}") 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: def is_required_channel_access_error(error: BaseException) -> bool:
message = str(error).lower() message = str(error).lower()
configuration_markers = ( configuration_markers = (
+9
View File
@@ -29,6 +29,15 @@ class AsyncTTLCache:
return None return None
return value 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 @staticmethod
def _is_cacheable(value: Any) -> bool: def _is_cacheable(value: Any) -> bool:
if value is None: if value is None:
+4
View File
@@ -100,6 +100,10 @@ class Settings(BaseSettings):
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_ALL_USERS_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_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_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
+6 -2
View File
@@ -59,6 +59,10 @@
| `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. |
| `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. |
| `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. |
| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. |
| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. |
| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. |
| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. |
| `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. |
| `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
@@ -104,8 +108,8 @@
| `TERMS_OF_SERVICE_URL` | Условия использования. | | `TERMS_OF_SERVICE_URL` | Условия использования. |
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. | | `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
| `USER_AGREEMENT_URL` | Пользовательское соглашение. | | `USER_AGREEMENT_URL` | Пользовательское соглашение. |
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. | | `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. |
| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. | | `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. |
| `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. | | `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. |
| `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. | | `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. |
+2 -2
View File
@@ -78,7 +78,7 @@
activationPaymentFailed, activationPaymentFailed,
createActivationHandoff, createActivationHandoff,
} from "./lib/webapp/activationHandoff.js"; } from "./lib/webapp/activationHandoff.js";
import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js";
import { createBillingActions } from "./lib/webapp/billingActions.js"; import { createBillingActions } from "./lib/webapp/billingActions.js";
import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js";
import { runWebappBoot } from "./lib/webapp/webappBoot.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js";
@@ -494,7 +494,7 @@
$: telegramProfileName = telegramName(user); $: telegramProfileName = telegramName(user);
$: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileEmail = user?.email || t("wa_settings_email_not_linked");
$: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked");
$: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl);
$: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim();
$: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim();
$: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim();
@@ -53,6 +53,26 @@
return String(val ?? "—"); return String(val ?? "—");
} }
function isTrialSubscription(sub) {
return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial");
}
function subscriptionDisplayLabel(sub) {
if (!sub) return "—";
if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал");
if (sub.display_label) return sub.display_label;
return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа");
}
function trialSummaryText(trial) {
if (!trial?.used) return at("user_trial_not_used", {}, "Не брал");
const date = trial.latest_activated_at || trial.first_activated_at;
const base = date
? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`)
: at("user_trial_used", {}, "Брал");
return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base;
}
const usersStore = getContext("usersStore"); const usersStore = getContext("usersStore");
$: ({ $: ({
@@ -404,7 +424,7 @@
</li> </li>
<li> <li>
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong <span>{at("user_label_tariff", {}, "Тариф")}</span><strong
>{openedUserDetail.active_subscription.tariff_key || "—"}</strong >{subscriptionDisplayLabel(openedUserDetail.active_subscription)}</strong
> >
</li> </li>
<li> <li>
@@ -507,6 +527,37 @@
</p> </p>
{/if} {/if}
{#if openedUserDetail?.trial}
<ul class="admin-meta-list">
<li>
<span>{at("user_label_trial", {}, "Пробник / триал")}</span><strong
>{trialSummaryText(openedUserDetail.trial)}</strong
>
</li>
{#if openedUserDetail.trial.used && openedUserDetail.trial.latest_end_date}
<li>
<span>{at("user_label_trial_until", {}, "Триал до")}</span><strong
>{fmtDate(openedUserDetail.trial.latest_end_date)}</strong
>
</li>
{/if}
{#if Number(openedUserDetail.trial.count || 0) > 1}
<li>
<span>{at("user_label_trial_count", {}, "Триалов")}</span><strong
>{openedUserDetail.trial.count}</strong
>
</li>
{/if}
{#if openedUserDetail.trial.last_reset_at}
<li>
<span>{at("user_label_trial_reset_at", {}, "Сброс триала")}</span><strong
>{fmtDate(openedUserDetail.trial.last_reset_at)}</strong
>
</li>
{/if}
</ul>
{/if}
{#if (openedUserDetail.subscriptions || []).length} {#if (openedUserDetail.subscriptions || []).length}
<Separator.Root class="admin-separator" /> <Separator.Root class="admin-separator" />
<div class="admin-subsection-title"> <div class="admin-subsection-title">
@@ -521,8 +572,7 @@
<div class="admin-mini-list-row"> <div class="admin-mini-list-row">
<div> <div>
<strong <strong
>{sub.tariff_key || >{subscriptionDisplayLabel(sub)}</strong
at("user_history_no_tariff", {}, "Без тарифа")}</strong
> >
<small <small
>{at( >{at(
+13 -4
View File
@@ -4,16 +4,25 @@ function bytesToHex(buffer) {
async function sha256Hex(value) { async function sha256Hex(value) {
const data = new TextEncoder().encode(value); const data = new TextEncoder().encode(value);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); const hashBuffer = await globalThis.crypto?.subtle?.digest("SHA-256", data);
return bytesToHex(hashBuffer); return bytesToHex(hashBuffer);
} }
export async function buildGravatarUrl(emailValue) { export async function buildGravatarUrl(emailValue) {
if (!emailValue || !window.crypto?.subtle) return ""; const email = String(emailValue || "")
.trim()
.toLowerCase();
if (!email || !globalThis.crypto?.subtle) return "";
try { try {
const hash = await sha256Hex(emailValue); const hash = await sha256Hex(email);
return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`; return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=160`;
} catch { } catch {
return ""; return "";
} }
} }
export function resolveProfileAvatarUrl(user, emailAvatarUrl = "") {
const telegramAvatar = String(user?.telegram_photo_url || "").trim();
if (user?.telegram_linked && telegramAvatar) return telegramAvatar;
return String(emailAvatarUrl || "").trim();
}
+17
View File
@@ -330,6 +330,23 @@
background: color-mix(in srgb, var(--accent) 90%, #fff); background: color-mix(in srgb, var(--accent) 90%, #fff);
} }
@media (max-width: 640px) {
.admin-extend-control .input,
.admin-extend-control .admin-btn {
height: 46px;
min-height: 46px;
}
.admin-extend-control .input {
line-height: 46px;
}
.admin-extend-control .admin-btn {
width: 100%;
border-width: 1px 0 0;
}
}
.admin-input-row .input, .admin-input-row .input,
.admin-input-row .admin-btn { .admin-input-row .admin-btn {
height: 36px; height: 36px;
+25
View File
@@ -233,6 +233,31 @@ a {
color: var(--danger); color: var(--danger);
} }
.status-card-warning {
border-color: var(--warning-border);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--warning) 14%, var(--surface-sheen-soft)),
color-mix(in srgb, var(--warning) 8%, var(--surface-sheen-soft))
),
var(--panel);
box-shadow:
var(--shadow-soft),
0 0 0 1px color-mix(in srgb, var(--warning) 20%, transparent),
inset 0 1px 0 var(--inset-highlight);
}
.status-card-warning .sub-status {
color: var(--warning-text);
}
.status-card-warning .subscription-end-line {
color: var(--warning-text);
font-variant-numeric: tabular-nums;
opacity: 1;
}
.sub-status-inactive { .sub-status-inactive {
min-height: 0; min-height: 0;
justify-content: flex-start; justify-content: flex-start;
+78 -6
View File
@@ -1,4 +1,5 @@
<script> <script>
import { onMount } from "svelte";
import { import {
CheckCircle2, CheckCircle2,
CircleQuestionMark, CircleQuestionMark,
@@ -27,6 +28,9 @@
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn, activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
} from "../../lib/webapp/traffic.js"; } from "../../lib/webapp/traffic.js";
const SUBSCRIPTION_EXPIRY_WARNING_MS = 72 * 60 * 60 * 1000;
const SUBSCRIPTION_EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
export let appSettings = {}; export let appSettings = {};
export let brand = {}; export let brand = {};
export let brandTitle = ""; export let brandTitle = "";
@@ -46,6 +50,8 @@
export let trialBusy = false; export let trialBusy = false;
export let termUnitLabel = () => ""; // We need this passed from App or context. Actually, App.svelte doesn't pass it yet. We'll pass it. export let termUnitLabel = () => ""; // We need this passed from App or context. Actually, App.svelte doesn't pass it yet. We'll pass it.
let nowMs = Date.now();
function trafficPercent(sub) { function trafficPercent(sub) {
return trafficPercentFn(sub); return trafficPercentFn(sub);
} }
@@ -106,10 +112,78 @@
unit: termUnitLabel(days, "day"), unit: termUnitLabel(days, "day"),
}); });
} }
function parseSubscriptionEndMs(sub) {
const raw = String(sub?.end_date || "").trim();
if (!raw) return null;
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function dateOnlyFromEndText(text) {
const value = String(text || "").trim();
if (!value) return "";
return value.split(/\s+/)[0] || value;
}
function dateOnlyFromIso(text) {
const match = String(text || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : "";
}
function subscriptionEndDateLabel(sub) {
return dateOnlyFromEndText(sub?.end_date_text) || dateOnlyFromIso(sub?.end_date);
}
function formatSubscriptionCountdown(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (value) => String(value).padStart(2, "0");
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
$: trialOfferAvailable = Boolean( $: trialOfferAvailable = Boolean(
!subscription?.active && appSettings?.trial_enabled && appSettings?.trial_available !subscription?.active && appSettings?.trial_enabled && appSettings?.trial_available
); );
$: subscriptionEndMs = subscription?.active ? parseSubscriptionEndMs(subscription) : null;
$: subscriptionRemainingMs = Math.max(0, Number(subscriptionEndMs || 0) - nowMs);
$: subscriptionExpiryWarning = Boolean(
subscription?.active &&
subscriptionEndMs &&
subscriptionRemainingMs > 0 &&
subscriptionRemainingMs <= SUBSCRIPTION_EXPIRY_WARNING_MS
);
$: subscriptionEndDateText = subscriptionEndDateLabel(subscription);
$: subscriptionEndCountdown = formatSubscriptionCountdown(subscriptionRemainingMs);
$: subscriptionEndCountdownLabel = t(
"wa_subscription_remaining_countdown",
{ countdown: subscriptionEndCountdown },
`осталось: ${subscriptionEndCountdown}`
);
$: subscriptionExpiringSoon = Boolean(
subscription?.active &&
subscriptionEndMs &&
subscriptionRemainingMs > 0 &&
subscriptionRemainingMs < SUBSCRIPTION_EXPIRING_SOON_MS
);
$: subscriptionTermDisplayText = subscriptionExpiringSoon
? t("wa_subscription_expiring_soon", {}, "Скоро закончится!")
: activeSubscriptionTermLabel(subscription);
$: subscriptionEndDisplayText = subscriptionExpiryWarning
? `${subscriptionEndDateText || subscription.end_date_text} \u00b7 ${subscriptionEndCountdownLabel}`
: subscriptionEndDateText;
$: statusCardClass = [
"status-card",
subscription.active ? "" : "status-card-inactive",
subscriptionExpiryWarning ? "status-card-warning" : "",
]
.filter(Boolean)
.join(" ");
onMount(() => {
const countdownTimer = window.setInterval(() => {
if (subscription?.active) nowMs = Date.now();
}, 1000);
return () => window.clearInterval(countdownTimer);
});
export let activateTrial = () => {}; export let activateTrial = () => {};
export let openConnectLink = () => {}; export let openConnectLink = () => {};
@@ -138,15 +212,13 @@
{/if} {/if}
<div class="home-bottom"> <div class="home-bottom">
<Card class={`status-card${subscription.active ? "" : " status-card-inactive"}`}> <Card class={statusCardClass}>
{#if subscription.active} {#if subscription.active}
<div class="sub-status"> <div class="sub-status">
<CheckCircle2 class="sub-status-icon" size={23} /> <CheckCircle2 class="sub-status-icon" size={23} />
<div class="sub-status-main"> <div class="sub-status-main">
<h2> <h2>
{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel( {trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {subscriptionTermDisplayText}
subscription
)}
</h2> </h2>
<div <div
class:sub-status-details-with-tariff={hasActiveTariffSubscription && class:sub-status-details-with-tariff={hasActiveTariffSubscription &&
@@ -160,8 +232,8 @@
</p> </p>
{/if} {/if}
<p class="subscription-end-line"> <p class="subscription-end-line">
{subscription.end_date_text {subscriptionEndDisplayText
? t("wa_until_date", { date: subscription.end_date_text }) ? t("wa_until_date", { date: subscriptionEndDisplayText })
: subscription.remaining_text} : subscription.remaining_text}
</p> </p>
</div> </div>
+10
View File
@@ -731,6 +731,7 @@
"wa_auth_legal_agreement": "user agreement", "wa_auth_legal_agreement": "user agreement",
"wa_home_subscription_active": "Subscription active", "wa_home_subscription_active": "Subscription active",
"wa_home_access_active": "Access active", "wa_home_access_active": "Access active",
"wa_subscription_expiring_soon": "Ending soon!",
"wa_sub_term_forever": "Forever", "wa_sub_term_forever": "Forever",
"wa_sub_term_value_unit": "{value} {unit}", "wa_sub_term_value_unit": "{value} {unit}",
"wa_sub_term_day_one": "day", "wa_sub_term_day_one": "day",
@@ -744,6 +745,7 @@
"wa_sub_term_year_many": "years", "wa_sub_term_year_many": "years",
"wa_home_subscription_inactive": "Subscription inactive", "wa_home_subscription_inactive": "Subscription inactive",
"wa_until_date": "until {date}", "wa_until_date": "until {date}",
"wa_subscription_remaining_countdown": "left: {countdown}",
"wa_home_traffic_used": "Traffic used", "wa_home_traffic_used": "Traffic used",
"wa_premium_traffic_title": "Premium servers", "wa_premium_traffic_title": "Premium servers",
"wa_premium_reset_monthly": "Separate monthly limit", "wa_premium_reset_monthly": "Separate monthly limit",
@@ -1554,6 +1556,14 @@
"admin_settings_field_panel_api_url_description": "For example, https://panel.example.com/api.", "admin_settings_field_panel_api_url_description": "For example, https://panel.example.com/api.",
"admin_settings_field_panel_api_key_label": "Remnawave API key", "admin_settings_field_panel_api_key_label": "Remnawave API key",
"admin_settings_field_panel_api_key_description": "Secret API key for the panel.", "admin_settings_field_panel_api_key_description": "Secret API key for the panel.",
"admin_settings_field_panel_api_total_timeout_seconds_label": "Remnawave API total timeout",
"admin_settings_field_panel_api_total_timeout_seconds_description": "Maximum total time for one Remnawave API request, in seconds.",
"admin_settings_field_panel_api_connect_timeout_seconds_label": "Remnawave API connect timeout",
"admin_settings_field_panel_api_connect_timeout_seconds_description": "Maximum time to get or open a Remnawave API connection, in seconds.",
"admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "Remnawave API TCP/TLS timeout",
"admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Maximum TCP/TLS connection time for Remnawave API, in seconds.",
"admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Remnawave API read timeout",
"admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Maximum time to wait for response data from Remnawave API, in seconds.",
"admin_settings_field_panel_webhook_secret_label": "Remnawave webhook secret", "admin_settings_field_panel_webhook_secret_label": "Remnawave webhook secret",
"admin_settings_field_panel_webhook_secret_description": "Set the secret in Remnawave Panel and paste the same value here to verify incoming panel webhooks.", "admin_settings_field_panel_webhook_secret_description": "Set the secret in Remnawave Panel and paste the same value here to verify incoming panel webhooks.",
"admin_settings_field_user_squad_uuids_label": "Default Internal Squads", "admin_settings_field_user_squad_uuids_label": "Default Internal Squads",
+10
View File
@@ -731,6 +731,7 @@
"wa_auth_legal_agreement": "пользовательским соглашением", "wa_auth_legal_agreement": "пользовательским соглашением",
"wa_home_subscription_active": "Подписка активна", "wa_home_subscription_active": "Подписка активна",
"wa_home_access_active": "Доступ активен", "wa_home_access_active": "Доступ активен",
"wa_subscription_expiring_soon": "Скоро закончится!",
"wa_sub_term_forever": "Навсегда", "wa_sub_term_forever": "Навсегда",
"wa_sub_term_value_unit": "{value} {unit}", "wa_sub_term_value_unit": "{value} {unit}",
"wa_sub_term_day_one": "день", "wa_sub_term_day_one": "день",
@@ -744,6 +745,7 @@
"wa_sub_term_year_many": "лет", "wa_sub_term_year_many": "лет",
"wa_home_subscription_inactive": "Подписка не активна", "wa_home_subscription_inactive": "Подписка не активна",
"wa_until_date": "до {date}", "wa_until_date": "до {date}",
"wa_subscription_remaining_countdown": "осталось: {countdown}",
"wa_home_traffic_used": "Использовано трафика", "wa_home_traffic_used": "Использовано трафика",
"wa_premium_traffic_title": "Premium-серверы", "wa_premium_traffic_title": "Premium-серверы",
"wa_premium_reset_monthly": "Отдельный лимит на месяц", "wa_premium_reset_monthly": "Отдельный лимит на месяц",
@@ -1554,6 +1556,14 @@
"admin_settings_field_panel_api_url_description": "Например, https://panel.example.com/api.", "admin_settings_field_panel_api_url_description": "Например, https://panel.example.com/api.",
"admin_settings_field_panel_api_key_label": "API-ключ Remnawave", "admin_settings_field_panel_api_key_label": "API-ключ Remnawave",
"admin_settings_field_panel_api_key_description": "Секретный ключ API панели.", "admin_settings_field_panel_api_key_description": "Секретный ключ API панели.",
"admin_settings_field_panel_api_total_timeout_seconds_label": "Общий таймаут API Remnawave",
"admin_settings_field_panel_api_total_timeout_seconds_description": "Максимальное время одного запроса к Remnawave API, в секундах.",
"admin_settings_field_panel_api_connect_timeout_seconds_label": "Таймаут подключения API Remnawave",
"admin_settings_field_panel_api_connect_timeout_seconds_description": "Максимальное время получения или открытия соединения с Remnawave API, в секундах.",
"admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "TCP/TLS-таймаут API Remnawave",
"admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Максимальное время TCP/TLS-подключения к Remnawave API, в секундах.",
"admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Таймаут чтения API Remnawave",
"admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Максимальное ожидание данных ответа от Remnawave API, в секундах.",
"admin_settings_field_panel_webhook_secret_label": "Секрет вебхуков Remnawave", "admin_settings_field_panel_webhook_secret_label": "Секрет вебхуков Remnawave",
"admin_settings_field_panel_webhook_secret_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.", "admin_settings_field_panel_webhook_secret_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.",
"admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию", "admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию",
@@ -264,10 +264,15 @@ def test_remnawave_settings_include_panel_webhook_metadata():
remnawave_keys = ( remnawave_keys = (
"PANEL_API_URL", "PANEL_API_URL",
"PANEL_API_KEY", "PANEL_API_KEY",
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
"PANEL_WEBHOOK_SECRET", "PANEL_WEBHOOK_SECRET",
"USER_SQUAD_UUIDS", "USER_SQUAD_UUIDS",
"USER_EXTERNAL_SQUAD_UUID", "USER_EXTERNAL_SQUAD_UUID",
) )
timeout_keys = remnawave_keys[2:6]
assert field["webhook_path"] == "/webhook/panel" assert field["webhook_path"] == "/webhook/panel"
assert field["webhook_requires_base_url"] is True assert field["webhook_requires_base_url"] is True
@@ -278,10 +283,18 @@ def test_remnawave_settings_include_panel_webhook_metadata():
assert manifest[setting_key]["section_order"] == 3 assert manifest[setting_key]["section_order"] == 3
assert manifest[setting_key]["subsection"] is None assert manifest[setting_key]["subsection"] is None
for setting_key in timeout_keys:
assert manifest[setting_key]["type"] == "float"
assert manifest[setting_key]["optional"] is False
assert manifest[setting_key]["min"] == 1
for language in ("ru", "en"): for language in ("ru", "en"):
messages = _locale(language) messages = _locale(language)
assert "admin_settings_section_remnawave" in messages assert "admin_settings_section_remnawave" in messages
assert field["webhook_hint_i18n_key"] in messages assert field["webhook_hint_i18n_key"] in messages
for setting_key in timeout_keys:
assert manifest[setting_key]["i18n_label_key"] in messages
assert manifest[setting_key]["i18n_description_key"] in messages
def test_payment_provider_admin_only_toggles_are_mutually_exclusive(): def test_payment_provider_admin_only_toggles_are_mutually_exclusive():
+65
View File
@@ -1,8 +1,10 @@
import json import json
import unittest import unittest
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from bot.app.web.admin_api_impl import common as admin_common
from bot.app.web.admin_api_impl import users as admin_users from bot.app.web.admin_api_impl import users as admin_users
@@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase):
self.assertFalse(session.rolled_back) self.assertFalse(session.rolled_back)
class AdminUserTrialPresentationTests(unittest.TestCase):
def test_trial_subscription_serializes_display_label(self):
start_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc)
end_at = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
sub = SimpleNamespace(
subscription_id=7,
panel_user_uuid="panel-user",
panel_subscription_uuid=None,
start_date=start_at,
end_date=end_at,
duration_months=None,
is_active=False,
status_from_panel="EXPIRED",
traffic_limit_bytes=10,
traffic_used_bytes=2,
tier_baseline_bytes=0,
topup_balance_bytes=0,
premium_used_bytes=0,
premium_baseline_bytes=0,
premium_topup_balance_bytes=0,
premium_topup_used_bytes=0,
premium_bonus_bytes=0,
regular_bonus_bytes=0,
regular_unlimited_override=False,
premium_unlimited_override=False,
premium_is_limited=False,
tariff_key=None,
auto_renew_enabled=False,
provider="trial",
is_throttled=False,
)
payload = admin_common._serialize_subscription(sub)
self.assertTrue(payload["is_trial"])
self.assertEqual(payload["display_label"], "Trial")
self.assertIsNone(payload["tariff_key"])
def test_trial_summary_includes_usage_dates_and_reset_marker(self):
first_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc)
latest_at = datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc)
latest_end = datetime(2026, 2, 10, 4, 5, tzinfo=timezone.utc)
reset_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
user = SimpleNamespace(trial_eligibility_reset_at=reset_at)
trial_subs = [
SimpleNamespace(
start_date=first_at,
end_date=datetime(2026, 1, 9, tzinfo=timezone.utc),
),
SimpleNamespace(start_date=latest_at, end_date=latest_end, is_active=True),
]
payload = admin_users._serialize_trial_summary(user, trial_subs)
self.assertTrue(payload["used"])
self.assertTrue(payload["active"])
self.assertEqual(payload["count"], 2)
self.assertEqual(payload["first_activated_at"], first_at.isoformat())
self.assertEqual(payload["latest_activated_at"], latest_at.isoformat())
self.assertEqual(payload["latest_end_date"], latest_end.isoformat())
self.assertEqual(payload["last_reset_at"], reset_at.isoformat())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+92 -3
View File
@@ -9,6 +9,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
from bot.utils.channel_subscription import ( from bot.utils.channel_subscription import (
is_required_channel_access_error, is_required_channel_access_error,
normalize_required_channel_id, normalize_required_channel_id,
normalize_required_channel_link,
) )
@@ -21,10 +22,13 @@ class I18nStub:
class FakeBot: class FakeBot:
def __init__(self, *, status="member", error=None): def __init__(self, *, status="member", error=None, chat=None, chat_error=None):
self.status = status self.status = status
self.error = error self.error = error
self.chat = chat
self.chat_error = chat_error
self.calls = [] self.calls = []
self.get_chat_calls = []
async def get_chat_member(self, chat_id, user_id): async def get_chat_member(self, chat_id, user_id):
self.calls.append((chat_id, user_id)) self.calls.append((chat_id, user_id))
@@ -32,11 +36,17 @@ class FakeBot:
raise self.error raise self.error
return SimpleNamespace(status=self.status) return SimpleNamespace(status=self.status)
async def get_chat(self, chat_id):
self.get_chat_calls.append(chat_id)
if self.chat_error:
raise self.chat_error
return self.chat or SimpleNamespace(username="required_channel")
def _settings(required_channel_id):
def _settings(required_channel_id, required_channel_link="https://t.me/example"):
return SimpleNamespace( return SimpleNamespace(
REQUIRED_CHANNEL_ID=required_channel_id, REQUIRED_CHANNEL_ID=required_channel_id,
REQUIRED_CHANNEL_LINK="https://t.me/example", REQUIRED_CHANNEL_LINK=required_channel_link,
ADMIN_IDS=[], ADMIN_IDS=[],
DEFAULT_LANGUAGE="en", DEFAULT_LANGUAGE="en",
) )
@@ -70,6 +80,24 @@ class RequiredChannelIdNormalizationTests(unittest.TestCase):
self.assertIsNone(normalize_required_channel_id("")) self.assertIsNone(normalize_required_channel_id(""))
self.assertIsNone(normalize_required_channel_id(0)) self.assertIsNone(normalize_required_channel_id(0))
def test_normalizes_channel_links_for_join_button(self):
self.assertEqual(
normalize_required_channel_link("@required_channel"), "https://t.me/required_channel"
)
self.assertEqual(
normalize_required_channel_link("required_channel"), "https://t.me/required_channel"
)
self.assertEqual(
normalize_required_channel_link("t.me/required_channel"),
"https://t.me/required_channel",
)
self.assertEqual(
normalize_required_channel_link("https://t.me/required_channel"),
"https://t.me/required_channel",
)
self.assertEqual(normalize_required_channel_link("+inviteHash"), "https://t.me/+inviteHash")
self.assertIsNone(normalize_required_channel_link("not a valid link"))
def test_detects_channel_configuration_errors(self): def test_detects_channel_configuration_errors(self):
self.assertTrue( self.assertTrue(
is_required_channel_access_error( is_required_channel_access_error(
@@ -124,6 +152,33 @@ class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase):
event.answer.assert_awaited_once_with("check failed") event.answer.assert_awaited_once_with("check failed")
update_user.assert_not_awaited() update_user.assert_not_awaited()
async def test_join_button_prefers_channel_link_resolved_from_required_id(self):
bot = FakeBot(
status="left",
chat=SimpleNamespace(username="required_channel", invite_link=None),
)
event = _message_event(bot)
user = _db_user()
with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()):
result = await ensure_required_channel_subscription(
event,
_settings(1234567890, required_channel_link="https://t.me/main_sales_bot"),
I18nStub(),
"en",
AsyncMock(),
db_user=user,
)
self.assertFalse(result)
self.assertEqual(bot.calls, [(-1001234567890, 42)])
self.assertEqual(bot.get_chat_calls, [-1001234567890])
reply_markup = event.answer.await_args.kwargs["reply_markup"]
self.assertEqual(
reply_markup.inline_keyboard[0][0].url,
"https://t.me/required_channel",
)
class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase): class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self): async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
@@ -146,6 +201,40 @@ class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result, "ok") self.assertEqual(result, "ok")
handler.assert_awaited_once_with(event, data) handler.assert_awaited_once_with(event, data)
async def test_middleware_prompt_uses_channel_link_resolved_from_required_id(self):
middleware = ChannelSubscriptionMiddleware(
_settings(1234567890, required_channel_link="https://t.me/main_sales_bot"),
I18nStub(),
)
handler = AsyncMock(return_value="ok")
message = SimpleNamespace(text="menu", answer=AsyncMock())
event = SimpleNamespace(callback_query=None, message=message)
bot = FakeBot(
chat=SimpleNamespace(username="required_channel", invite_link=None),
)
data = {
"bot": bot,
"event_from_user": SimpleNamespace(id=42),
"session": AsyncMock(),
"i18n_data": {"current_language": "en", "i18n_instance": I18nStub()},
}
user = _db_user(verified=False, verified_for=None)
with patch(
"bot.middlewares.channel_subscription.user_dal.get_user_by_id",
AsyncMock(return_value=user),
):
result = await middleware(handler, event, data)
self.assertIsNone(result)
handler.assert_not_awaited()
self.assertEqual(bot.get_chat_calls, [-1001234567890])
reply_markup = message.answer.await_args.kwargs["reply_markup"]
self.assertEqual(
reply_markup.inline_keyboard[0][0].url,
"https://t.me/required_channel",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+65
View File
@@ -1,8 +1,11 @@
import asyncio import asyncio
import time
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import aiohttp
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
@@ -16,6 +19,68 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
) )
) )
async def test_client_timeout_uses_panel_settings(self):
service = PanelApiService(
SimpleNamespace(
PANEL_API_URL="https://panel.example.test/api",
PANEL_API_KEY="panel-key",
PANEL_API_TOTAL_TIMEOUT_SECONDS="30",
PANEL_API_CONNECT_TIMEOUT_SECONDS="10",
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS="9",
PANEL_API_SOCK_READ_TIMEOUT_SECONDS="20",
)
)
timeout = service._client_timeout()
self.assertEqual(timeout.total, 30)
self.assertEqual(timeout.connect, 10)
self.assertEqual(timeout.sock_connect, 9)
self.assertEqual(timeout.sock_read, 20)
async def test_get_request_retries_connection_timeout(self):
service = self._make_service()
request_calls = 0
class OkResponse:
status = 200
headers = {"Content-Type": "application/json"}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
return None
async def text(self):
return '{"response": {"ok": true}}'
def fake_request(*_args, **_kwargs):
nonlocal request_calls
request_calls += 1
if request_calls == 1:
raise aiohttp.ConnectionTimeoutError("connect took too long")
return OkResponse()
service._get_session = AsyncMock(return_value=SimpleNamespace(request=fake_request))
with patch("bot.services.panel_api_service.asyncio.sleep", new=AsyncMock()):
result = await service._request("GET", "/internal-squads")
self.assertEqual(result, {"response": {"ok": True}})
self.assertEqual(request_calls, 2)
async def test_get_internal_squads_uses_stale_cache_after_refresh_failure(self):
service = self._make_service()
stale_squads = [{"uuid": "squad-1", "name": "Squad 1"}]
service._squads_cache._data["list"] = (time.monotonic() - 1, stale_squads)
service._get_internal_squads_uncached = AsyncMock(return_value=None)
squads = await service.get_internal_squads()
self.assertEqual(squads, stale_squads)
service._get_internal_squads_uncached.assert_awaited_once()
async def test_update_user_details_does_not_log_full_response_by_default(self): async def test_update_user_details_does_not_log_full_response_by_default(self):
service = self._make_service() service = self._make_service()
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
+8
View File
@@ -42,6 +42,14 @@ class AsyncTTLCacheSingleflightTests(unittest.IsolatedAsyncioTestCase):
class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase): class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
def test_get_stale_returns_expired_cacheable_value(self):
cache = AsyncTTLCache(ttl_seconds=60)
value = {"ok": True}
cache._data["same"] = (time.monotonic() - 1, value)
self.assertIsNone(cache.get_fresh("same"))
self.assertEqual(cache.get_stale("same"), value)
async def test_invalidate_remote_deletes_single_redis_key(self): async def test_invalidate_remote_deletes_single_redis_key(self):
settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test") settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test")
cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench") cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench")
+117
View File
@@ -0,0 +1,117 @@
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import billing as billing_module
class _Session:
def __init__(self):
self.commit_count = 0
self.rollback_count = 0
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
self.commit_count += 1
async def rollback(self):
self.rollback_count += 1
class _SessionFactory:
def __init__(self, session):
self.session = session
def __call__(self):
return self.session
class WebAppTrialActivationTests(IsolatedAsyncioTestCase):
async def test_email_only_trial_activation_is_written_to_admin_logs(self):
session = _Session()
end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
settings = SimpleNamespace(
TRIAL_ENABLED=True,
TRIAL_DURATION_DAYS=7,
TRIAL_TRAFFIC_LIMIT_GB=10,
LOG_TRIAL_ACTIVATIONS=False,
)
db_user = SimpleNamespace(
user_id=42,
is_banned=False,
username=None,
first_name=None,
email="email-only@example.com",
)
subscription_service = SimpleNamespace(
activate_trial_subscription=AsyncMock(
return_value={
"activated": True,
"days": 7,
"end_date": end_date,
"traffic_gb": 10,
"subscription_url": "https://panel.example/sub",
}
)
)
request = SimpleNamespace(
app={
"settings": settings,
"async_session_factory": _SessionFactory(session),
"subscription_service": subscription_service,
}
)
with (
patch.object(billing_module, "_require_user_id", return_value=42),
patch.object(
billing_module,
"_enforce_webapp_rate_limit",
AsyncMock(return_value=None),
),
patch.object(
billing_module.user_dal,
"get_user_by_id",
AsyncMock(return_value=db_user),
),
patch.object(
billing_module,
"prepare_config_links",
AsyncMock(return_value=("https://panel.example/sub", "https://connect.example")),
),
patch.object(
billing_module.message_log_dal,
"create_message_log_no_commit",
AsyncMock(),
) as create_log,
patch.object(
billing_module,
"invalidate_webapp_user_caches",
AsyncMock(),
),
patch("db.dal.ad_dal.mark_trial_activated", AsyncMock()) as mark_trial_activated,
):
response = await billing_module.activate_trial_route(request)
payload = json.loads(response.text)
self.assertEqual(response.status, 200)
self.assertTrue(payload["activated"])
subscription_service.activate_trial_subscription.assert_awaited_once_with(session, 42)
create_log.assert_awaited_once()
log_payload = create_log.await_args.args[1]
self.assertEqual(log_payload["user_id"], 42)
self.assertEqual(log_payload["target_user_id"], 42)
self.assertEqual(log_payload["event_type"], "webapp_trial_activate")
self.assertFalse(log_payload["is_admin_event"])
self.assertIn("email-only@example.com", log_payload["content"])
mark_trial_activated.assert_awaited_once_with(session, 42)
self.assertEqual(session.commit_count, 2)
self.assertEqual(session.rollback_count, 0)