Merge branch 'dev' into feature/migration-remnashop
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
| `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. |
|
||||
| `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. |
|
||||
| `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_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
|
||||
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
|
||||
@@ -104,8 +108,8 @@
|
||||
| `TERMS_OF_SERVICE_URL` | Условия использования. |
|
||||
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
|
||||
| `USER_AGREEMENT_URL` | Пользовательское соглашение. |
|
||||
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. |
|
||||
| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. |
|
||||
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. |
|
||||
| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. |
|
||||
| `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. |
|
||||
| `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. |
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
activationPaymentFailed,
|
||||
createActivationHandoff,
|
||||
} 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 { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js";
|
||||
import { runWebappBoot } from "./lib/webapp/webappBoot.js";
|
||||
@@ -494,7 +494,7 @@
|
||||
$: telegramProfileName = telegramName(user);
|
||||
$: profileEmail = user?.email || t("wa_settings_email_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();
|
||||
$: userAgreementUrl = String(CFG.userAgreementUrl || "").trim();
|
||||
$: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim();
|
||||
|
||||
@@ -53,6 +53,26 @@
|
||||
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");
|
||||
|
||||
$: ({
|
||||
@@ -404,7 +424,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong
|
||||
>{openedUserDetail.active_subscription.tariff_key || "—"}</strong
|
||||
>{subscriptionDisplayLabel(openedUserDetail.active_subscription)}</strong
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
@@ -507,6 +527,37 @@
|
||||
</p>
|
||||
{/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}
|
||||
<Separator.Root class="admin-separator" />
|
||||
<div class="admin-subsection-title">
|
||||
@@ -521,8 +572,7 @@
|
||||
<div class="admin-mini-list-row">
|
||||
<div>
|
||||
<strong
|
||||
>{sub.tariff_key ||
|
||||
at("user_history_no_tariff", {}, "Без тарифа")}</strong
|
||||
>{subscriptionDisplayLabel(sub)}</strong
|
||||
>
|
||||
<small
|
||||
>{at(
|
||||
|
||||
@@ -4,16 +4,25 @@ function bytesToHex(buffer) {
|
||||
|
||||
async function sha256Hex(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);
|
||||
}
|
||||
|
||||
export async function buildGravatarUrl(emailValue) {
|
||||
if (!emailValue || !window.crypto?.subtle) return "";
|
||||
const email = String(emailValue || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!email || !globalThis.crypto?.subtle) return "";
|
||||
try {
|
||||
const hash = await sha256Hex(emailValue);
|
||||
return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`;
|
||||
const hash = await sha256Hex(email);
|
||||
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=160`;
|
||||
} catch {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -330,6 +330,23 @@
|
||||
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 .admin-btn {
|
||||
height: 36px;
|
||||
|
||||
@@ -233,6 +233,31 @@ a {
|
||||
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 {
|
||||
min-height: 0;
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleQuestionMark,
|
||||
@@ -27,6 +28,9 @@
|
||||
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
|
||||
} 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 brand = {};
|
||||
export let brandTitle = "";
|
||||
@@ -46,6 +50,8 @@
|
||||
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.
|
||||
|
||||
let nowMs = Date.now();
|
||||
|
||||
function trafficPercent(sub) {
|
||||
return trafficPercentFn(sub);
|
||||
}
|
||||
@@ -106,10 +112,78 @@
|
||||
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(
|
||||
!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 openConnectLink = () => {};
|
||||
@@ -138,15 +212,13 @@
|
||||
{/if}
|
||||
|
||||
<div class="home-bottom">
|
||||
<Card class={`status-card${subscription.active ? "" : " status-card-inactive"}`}>
|
||||
<Card class={statusCardClass}>
|
||||
{#if subscription.active}
|
||||
<div class="sub-status">
|
||||
<CheckCircle2 class="sub-status-icon" size={23} />
|
||||
<div class="sub-status-main">
|
||||
<h2>
|
||||
{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(
|
||||
subscription
|
||||
)}
|
||||
{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {subscriptionTermDisplayText}
|
||||
</h2>
|
||||
<div
|
||||
class:sub-status-details-with-tariff={hasActiveTariffSubscription &&
|
||||
@@ -160,8 +232,8 @@
|
||||
</p>
|
||||
{/if}
|
||||
<p class="subscription-end-line">
|
||||
{subscription.end_date_text
|
||||
? t("wa_until_date", { date: subscription.end_date_text })
|
||||
{subscriptionEndDisplayText
|
||||
? t("wa_until_date", { date: subscriptionEndDisplayText })
|
||||
: subscription.remaining_text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -731,6 +731,7 @@
|
||||
"wa_auth_legal_agreement": "user agreement",
|
||||
"wa_home_subscription_active": "Subscription active",
|
||||
"wa_home_access_active": "Access active",
|
||||
"wa_subscription_expiring_soon": "Ending soon!",
|
||||
"wa_sub_term_forever": "Forever",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "day",
|
||||
@@ -744,6 +745,7 @@
|
||||
"wa_sub_term_year_many": "years",
|
||||
"wa_home_subscription_inactive": "Subscription inactive",
|
||||
"wa_until_date": "until {date}",
|
||||
"wa_subscription_remaining_countdown": "left: {countdown}",
|
||||
"wa_home_traffic_used": "Traffic used",
|
||||
"wa_premium_traffic_title": "Premium servers",
|
||||
"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_key_label": "Remnawave API key",
|
||||
"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_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",
|
||||
|
||||
@@ -731,6 +731,7 @@
|
||||
"wa_auth_legal_agreement": "пользовательским соглашением",
|
||||
"wa_home_subscription_active": "Подписка активна",
|
||||
"wa_home_access_active": "Доступ активен",
|
||||
"wa_subscription_expiring_soon": "Скоро закончится!",
|
||||
"wa_sub_term_forever": "Навсегда",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "день",
|
||||
@@ -744,6 +745,7 @@
|
||||
"wa_sub_term_year_many": "лет",
|
||||
"wa_home_subscription_inactive": "Подписка не активна",
|
||||
"wa_until_date": "до {date}",
|
||||
"wa_subscription_remaining_countdown": "осталось: {countdown}",
|
||||
"wa_home_traffic_used": "Использовано трафика",
|
||||
"wa_premium_traffic_title": "Premium-серверы",
|
||||
"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_key_label": "API-ключ Remnawave",
|
||||
"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_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.",
|
||||
"admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию",
|
||||
|
||||
@@ -264,10 +264,15 @@ def test_remnawave_settings_include_panel_webhook_metadata():
|
||||
remnawave_keys = (
|
||||
"PANEL_API_URL",
|
||||
"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",
|
||||
"USER_SQUAD_UUIDS",
|
||||
"USER_EXTERNAL_SQUAD_UUID",
|
||||
)
|
||||
timeout_keys = remnawave_keys[2:6]
|
||||
|
||||
assert field["webhook_path"] == "/webhook/panel"
|
||||
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]["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"):
|
||||
messages = _locale(language)
|
||||
assert "admin_settings_section_remnawave" 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():
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
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
|
||||
|
||||
|
||||
@@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,6 +9,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
normalize_required_channel_link,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,10 +22,13 @@ class I18nStub:
|
||||
|
||||
|
||||
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.error = error
|
||||
self.chat = chat
|
||||
self.chat_error = chat_error
|
||||
self.calls = []
|
||||
self.get_chat_calls = []
|
||||
|
||||
async def get_chat_member(self, chat_id, user_id):
|
||||
self.calls.append((chat_id, user_id))
|
||||
@@ -32,11 +36,17 @@ class FakeBot:
|
||||
raise self.error
|
||||
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(
|
||||
REQUIRED_CHANNEL_ID=required_channel_id,
|
||||
REQUIRED_CHANNEL_LINK="https://t.me/example",
|
||||
REQUIRED_CHANNEL_LINK=required_channel_link,
|
||||
ADMIN_IDS=[],
|
||||
DEFAULT_LANGUAGE="en",
|
||||
)
|
||||
@@ -70,6 +80,24 @@ class RequiredChannelIdNormalizationTests(unittest.TestCase):
|
||||
self.assertIsNone(normalize_required_channel_id(""))
|
||||
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):
|
||||
self.assertTrue(
|
||||
is_required_channel_access_error(
|
||||
@@ -124,6 +152,33 @@ class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase):
|
||||
event.answer.assert_awaited_once_with("check failed")
|
||||
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):
|
||||
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
|
||||
@@ -146,6 +201,40 @@ class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(result, "ok")
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohttp
|
||||
|
||||
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):
|
||||
service = self._make_service()
|
||||
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
||||
|
||||
@@ -42,6 +42,14 @@ class AsyncTTLCacheSingleflightTests(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):
|
||||
settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test")
|
||||
cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user