Merge branch 'dev' into feature/install-page
# Conflicts: # backend/bot/app/web/admin_api_impl/settings.py # backend/bot/app/web/webapp/cache_helpers.py # frontend/src/admin/sections/SettingsSection.svelte # tests/test_admin_settings_manifest_i18n.py
This commit is contained in:
@@ -19,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||
|
||||
fields = manifest_payload()
|
||||
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
|
||||
sections: Dict[str, Dict[str, Any]] = {}
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
@@ -53,6 +54,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
response_field["read_error"] = read_error
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||
if webhook_path:
|
||||
if not webhook_path.startswith("/"):
|
||||
webhook_path = f"/{webhook_path}"
|
||||
response_field["webhook_path"] = webhook_path
|
||||
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
|
||||
if webhook_base_url:
|
||||
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
|
||||
sections[section_id]["fields"].append(response_field)
|
||||
|
||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
||||
|
||||
@@ -562,7 +562,11 @@ def manifest_payload() -> List[dict]:
|
||||
same value so existing UIs that only read ``placeholder`` also show the
|
||||
hint inside the empty input.
|
||||
"""
|
||||
from bot.payment_providers import find_manifest_owner, manifest_field_default
|
||||
from bot.payment_providers import (
|
||||
find_manifest_owner,
|
||||
manifest_field_default,
|
||||
provider_webhook_metadata,
|
||||
)
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
@@ -587,10 +591,12 @@ def manifest_payload() -> List[dict]:
|
||||
)
|
||||
|
||||
default_value: Optional[str] = None
|
||||
webhook_metadata: Optional[dict] = None
|
||||
owner = find_manifest_owner(field.key)
|
||||
if owner is not None:
|
||||
spec, manifest_field = owner
|
||||
default_value = manifest_field_default(spec, manifest_field)
|
||||
webhook_metadata = provider_webhook_metadata(spec)
|
||||
|
||||
placeholder = field.placeholder
|
||||
if not placeholder and default_value:
|
||||
@@ -617,6 +623,8 @@ def manifest_payload() -> List[dict]:
|
||||
}
|
||||
if default_value is not None:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
item.update(webhook_metadata)
|
||||
if field.choices:
|
||||
item["choices"] = [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
from .auth import _hash_email_password
|
||||
from .auth import (
|
||||
_hash_email_password,
|
||||
_notify_account_merged,
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
@@ -109,7 +113,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
current_user.email = email
|
||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
if not merge_notice:
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
await session.commit()
|
||||
final_user_id = int(current_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(current_user)
|
||||
@@ -122,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -178,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_email_linked:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -345,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -401,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_telegram_linked and final_telegram_id:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
@@ -834,6 +834,45 @@ def _run_git_command(*args: str) -> str:
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _normalize_version_branch(raw_branch: str) -> str:
|
||||
branch = str(raw_branch or "").strip()
|
||||
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
|
||||
if branch.startswith(prefix):
|
||||
branch = branch[len(prefix) :]
|
||||
break
|
||||
if branch == "HEAD":
|
||||
return ""
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
|
||||
|
||||
|
||||
def _resolve_version_branch() -> str:
|
||||
for env_name in (
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
"GITHUB_REF_NAME",
|
||||
"CI_COMMIT_REF_NAME",
|
||||
):
|
||||
branch = _normalize_version_branch(os.getenv(env_name, ""))
|
||||
if branch:
|
||||
return branch
|
||||
return _normalize_version_branch(
|
||||
_run_git_command("branch", "--show-current")
|
||||
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
)
|
||||
|
||||
|
||||
def _format_app_version(tag: str, sha: str, branch: str) -> str:
|
||||
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
|
||||
if tag and sha:
|
||||
return f"{tag}{branch_suffix}+g{sha}"
|
||||
if sha:
|
||||
return f"dev{branch_suffix}+g{sha}"
|
||||
if tag:
|
||||
return f"{tag}{branch_suffix}"
|
||||
return f"dev{branch_suffix}+unknown"
|
||||
|
||||
|
||||
def _resolve_app_version() -> str:
|
||||
global _APP_VERSION_CACHE
|
||||
if _APP_VERSION_CACHE:
|
||||
@@ -855,21 +894,8 @@ def _resolve_app_version() -> str:
|
||||
|
||||
tag = _run_git_command("describe", "--tags", "--abbrev=0")
|
||||
sha = _run_git_command("rev-parse", "--short", "HEAD")
|
||||
dirty = bool(_run_git_command("status", "--porcelain"))
|
||||
|
||||
if tag and sha:
|
||||
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
|
||||
if commits_since_tag and commits_since_tag != "0":
|
||||
version = f"{tag}+{commits_since_tag}.g{sha}"
|
||||
else:
|
||||
version = tag
|
||||
elif sha:
|
||||
version = f"dev+g{sha}"
|
||||
else:
|
||||
version = "dev+unknown"
|
||||
|
||||
if dirty:
|
||||
version = f"{version}-dirty"
|
||||
branch = _resolve_version_branch()
|
||||
version = _format_app_version(tag, sha, branch)
|
||||
|
||||
_APP_VERSION_CACHE = version
|
||||
return version
|
||||
@@ -1372,10 +1398,10 @@ def _resolve_webapp_js_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_js_asset_name() -> str:
|
||||
return _resolve_hashed_js_asset_name(
|
||||
kind="admin-js",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
# The admin bundle is lazy-loaded from the already running Mini App. In
|
||||
# deployments where nginx serves static files in front of aiohttp, stale
|
||||
# hashed admin filenames can 404 even though the runtime build asset exists.
|
||||
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
|
||||
|
||||
|
||||
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||
@@ -1405,10 +1431,9 @@ def _resolve_webapp_css_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_css_asset_name() -> str:
|
||||
return _resolve_hashed_css_asset_name(
|
||||
kind="admin-css",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
# Keep the lazy-loaded admin stylesheet on the stable build filename for
|
||||
# the same reason as the JS bundle above.
|
||||
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
|
||||
|
||||
|
||||
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||
|
||||
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
redirect_path = "/settings" if purpose == "link" else "/"
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
final_user_id: Optional[int] = None
|
||||
source_user_id_for_cache: Optional[int] = None
|
||||
linked_user_for_panel: Optional[User] = None
|
||||
link_source_panel_uuid: Optional[str] = None
|
||||
link_final_panel_uuid: Optional[str] = None
|
||||
link_merge_notice: Optional[Dict[str, Any]] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if purpose == "link":
|
||||
current_user_id = int(state.get("user_id") or 0)
|
||||
source_user_id_for_cache = current_user_id
|
||||
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
|
||||
link_source_panel_uuid = (
|
||||
current_user_before_link.panel_user_uuid if current_user_before_link else None
|
||||
)
|
||||
db_user = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
telegram_user=telegram_user,
|
||||
settings=settings,
|
||||
)
|
||||
if int(db_user.user_id) != current_user_id:
|
||||
link_final_panel_uuid = db_user.panel_user_uuid
|
||||
link_merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=db_user,
|
||||
source_user_id=current_user_id,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
linked_user_for_panel = db_user
|
||||
else:
|
||||
db_user = await _ensure_user_from_telegram(
|
||||
session,
|
||||
@@ -388,6 +408,34 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
raise redirect(redirect_path, "failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
|
||||
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
|
||||
await _invalidate_webapp_user_caches(
|
||||
settings,
|
||||
source_user_id_for_cache,
|
||||
final_user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
|
||||
if purpose == "link" and link_merge_notice and linked_user_for_panel:
|
||||
merge_end_date_raw = link_merge_notice.get("final_end_date")
|
||||
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
linked_user_for_panel,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
final_panel_uuid=link_final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=link_merge_notice,
|
||||
email=linked_user_for_panel.email,
|
||||
telegram_id=_telegram_id_for_user(linked_user_for_panel),
|
||||
username=linked_user_for_panel.username,
|
||||
first_name=linked_user_for_panel.first_name,
|
||||
)
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -974,6 +1022,14 @@ def _panel_description_for_user(user: User) -> str:
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
return None
|
||||
value = str(raw_value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
async def _sync_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
@@ -995,7 +1051,11 @@ async def _sync_panel_identity_for_user(
|
||||
if user.email:
|
||||
payload["email"] = user.email
|
||||
if expire_at is not None:
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
if expire_at > datetime.now(timezone.utc):
|
||||
payload["status"] = "ACTIVE"
|
||||
|
||||
try:
|
||||
await subscription_service.panel_service.update_user_details_on_panel(
|
||||
@@ -1013,6 +1073,53 @@ async def _sync_panel_identity_for_user(
|
||||
return False
|
||||
|
||||
|
||||
async def _delete_merged_source_panel_user(
|
||||
request: web.Request,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
) -> bool:
|
||||
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
|
||||
return True
|
||||
|
||||
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_merged_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
expire_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
|
||||
# before patching the surviving one so merged accounts can accept both IDs.
|
||||
await _delete_merged_source_panel_user(
|
||||
request,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
|
||||
)
|
||||
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
|
||||
|
||||
|
||||
async def _build_account_merge_notice(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -1050,6 +1157,42 @@ async def _build_account_merge_notice(
|
||||
}
|
||||
|
||||
|
||||
async def _notify_account_merged(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
*,
|
||||
merge_notice: Optional[Dict[str, Any]],
|
||||
email: Optional[str],
|
||||
telegram_id: Optional[int],
|
||||
username: Optional[str],
|
||||
first_name: Optional[str],
|
||||
) -> None:
|
||||
if not merge_notice:
|
||||
return
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_account_merged(
|
||||
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
|
||||
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
|
||||
email=email,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
|
||||
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
|
||||
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send account merged notification")
|
||||
|
||||
|
||||
def _apply_telegram_profile_to_user(
|
||||
user: User,
|
||||
telegram_user: Dict[str, Any],
|
||||
@@ -1102,7 +1245,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
@@ -1134,7 +1276,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
||||
|
||||
@@ -75,15 +75,25 @@ def invalidate_local_webapp_user_payload(
|
||||
|
||||
def invalidate_all_local_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
namespace: Optional[str] = None,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
include_devices: Optional[bool] = None,
|
||||
) -> None:
|
||||
namespaces = set(_payload_namespaces(include_devices))
|
||||
if include_devices is not None:
|
||||
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
|
||||
elif namespace is not None:
|
||||
namespaces = {namespace}
|
||||
else:
|
||||
namespaces = None
|
||||
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(
|
||||
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||
):
|
||||
if settings_id == id(settings) and cache_namespace in namespaces:
|
||||
cache.invalidate()
|
||||
if settings_id != id(settings):
|
||||
continue
|
||||
if namespaces is not None and cache_namespace not in namespaces:
|
||||
continue
|
||||
cache.invalidate()
|
||||
|
||||
|
||||
async def invalidate_webapp_user_caches(
|
||||
@@ -117,10 +127,18 @@ async def invalidate_all_webapp_user_payloads(
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
invalidate_all_local_webapp_user_payloads(settings, include_devices=include_devices)
|
||||
for namespace in _payload_namespaces(include_devices):
|
||||
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
|
||||
try:
|
||||
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
|
||||
await cache_delete_pattern(settings, pattern)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from aiogram import Bot, Router, types
|
||||
@@ -42,8 +42,55 @@ def _normalize_description(value: Optional[str]) -> str:
|
||||
return "\n".join((value or "").split()).strip()
|
||||
|
||||
|
||||
def _repair_cp1251_mojibake(value: str) -> str:
|
||||
try:
|
||||
return value.encode("latin1").decode("cp1251")
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
return value
|
||||
|
||||
|
||||
def _description_variants(value: Optional[str]) -> set[str]:
|
||||
normalized = _normalize_description(value)
|
||||
variants = {normalized}
|
||||
repaired = _normalize_description(_repair_cp1251_mojibake(normalized))
|
||||
if repaired:
|
||||
variants.add(repaired)
|
||||
return variants
|
||||
|
||||
|
||||
def _description_matches(current: Optional[str], desired: str) -> bool:
|
||||
return _normalize_description(current) == _normalize_description(desired)
|
||||
return bool(_description_variants(current) & _description_variants(desired))
|
||||
|
||||
|
||||
def _panel_identity_matches_user(
|
||||
panel_user: dict[str, Any],
|
||||
user: User,
|
||||
desired_description: str,
|
||||
) -> bool:
|
||||
if desired_description and not _description_matches(
|
||||
panel_user.get("description"),
|
||||
desired_description,
|
||||
):
|
||||
return False
|
||||
|
||||
if user.email and _normalize_panel_email(panel_user.get("email")) != user.email.strip().lower():
|
||||
return False
|
||||
|
||||
if user.telegram_id and _coerce_panel_telegram_id(panel_user.get("telegramId")) != int(
|
||||
user.telegram_id
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"description": description_text}
|
||||
if user.email:
|
||||
payload["email"] = user.email
|
||||
if user.telegram_id:
|
||||
payload["telegramId"] = user.telegram_id
|
||||
return payload
|
||||
|
||||
|
||||
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
|
||||
@@ -61,6 +108,21 @@ def _as_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _panel_expire_at(panel_user: dict[str, Any]) -> Optional[datetime]:
|
||||
raw_value = panel_user.get("expireAt")
|
||||
if not raw_value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(raw_value).replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _panel_subscription_uuid(panel_user: dict[str, Any]) -> Optional[str]:
|
||||
value = panel_user.get("subscriptionUuid") or panel_user.get("shortUuid")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _should_update_lifetime_used_traffic(
|
||||
existing_user,
|
||||
lifetime_used: int,
|
||||
@@ -303,6 +365,211 @@ async def _bind_panel_email_to_user(
|
||||
return existing_user, True
|
||||
|
||||
|
||||
async def _merge_local_duplicate_panel_user_if_needed(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
existing_user,
|
||||
duplicate_panel_uuid: str,
|
||||
):
|
||||
duplicate_local_user = await user_dal.get_user_by_panel_uuid(session, duplicate_panel_uuid)
|
||||
if not duplicate_local_user or duplicate_local_user.user_id == existing_user.user_id:
|
||||
return existing_user, True
|
||||
|
||||
try:
|
||||
merged_user = await user_dal.merge_users(
|
||||
session,
|
||||
source_user_id=duplicate_local_user.user_id,
|
||||
target_user_id=existing_user.user_id,
|
||||
)
|
||||
logging.info(
|
||||
"Sync: merged local duplicate user %s into %s for duplicate panel UUID %s.",
|
||||
duplicate_local_user.user_id,
|
||||
merged_user.user_id,
|
||||
duplicate_panel_uuid,
|
||||
)
|
||||
return merged_user, True
|
||||
except Exception as exc:
|
||||
logging.warning(
|
||||
"Sync: could not merge local duplicate user %s into %s for panel UUID %s: %s",
|
||||
duplicate_local_user.user_id,
|
||||
existing_user.user_id,
|
||||
duplicate_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
return existing_user, False
|
||||
|
||||
|
||||
def _panel_identity_payload_with_expiry(
|
||||
user,
|
||||
*,
|
||||
expire_at: datetime,
|
||||
) -> dict[str, Any]:
|
||||
description_text = "\n".join(
|
||||
line
|
||||
for line in [
|
||||
user.email or "",
|
||||
user.username or "",
|
||||
user.first_name or "",
|
||||
user.last_name or "",
|
||||
]
|
||||
if line
|
||||
)
|
||||
payload = _panel_identity_update_payload(user, description_text)
|
||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
if expire_at > datetime.now(timezone.utc):
|
||||
payload["status"] = "ACTIVE"
|
||||
return payload
|
||||
|
||||
|
||||
async def _absorb_duplicate_panel_identity(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
panel_service: PanelApiService,
|
||||
existing_user,
|
||||
keep_panel_uuid: str,
|
||||
keep_panel_user: Optional[dict[str, Any]],
|
||||
duplicate_panel_user: dict[str, Any],
|
||||
settings: Settings,
|
||||
subscriptions_by_panel_uuid: dict[str, Subscription],
|
||||
active_subscriptions_by_user_panel: dict[tuple[int, str], Subscription],
|
||||
) -> dict[str, int | bool]:
|
||||
duplicate_panel_uuid = str(duplicate_panel_user.get("uuid") or "")
|
||||
if not duplicate_panel_uuid:
|
||||
return {"resolved": False, "subscriptions_created": 0, "subscriptions_updated": 0}
|
||||
|
||||
subscriptions_created = 0
|
||||
subscriptions_updated = 0
|
||||
now = datetime.now(timezone.utc)
|
||||
duplicate_expire_at = _panel_expire_at(duplicate_panel_user)
|
||||
duplicate_status = str(duplicate_panel_user.get("status") or "").upper()
|
||||
duplicate_is_active = bool(
|
||||
duplicate_expire_at and duplicate_status == "ACTIVE" and duplicate_expire_at > now
|
||||
)
|
||||
|
||||
keep_subscription_uuid = _panel_subscription_uuid(keep_panel_user or {})
|
||||
target_sub = (
|
||||
subscriptions_by_panel_uuid.get(keep_subscription_uuid) if keep_subscription_uuid else None
|
||||
)
|
||||
if not target_sub:
|
||||
target_sub = active_subscriptions_by_user_panel.get(
|
||||
(int(existing_user.user_id), keep_panel_uuid)
|
||||
)
|
||||
|
||||
final_end_date: Optional[datetime] = None
|
||||
if duplicate_is_active and duplicate_expire_at:
|
||||
source_remaining = max(timedelta(0), duplicate_expire_at - now)
|
||||
if target_sub:
|
||||
target_end = _as_utc(target_sub.end_date)
|
||||
base_end = target_end if target_end > now else now
|
||||
final_end_date = base_end + source_remaining
|
||||
update_payload: dict[str, Any] = {
|
||||
"user_id": int(existing_user.user_id),
|
||||
"panel_user_uuid": keep_panel_uuid,
|
||||
"end_date": final_end_date,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE",
|
||||
}
|
||||
if keep_subscription_uuid:
|
||||
update_payload["panel_subscription_uuid"] = keep_subscription_uuid
|
||||
update_delta = _subscription_update_delta(target_sub, update_payload)
|
||||
if update_delta:
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
target_sub.subscription_id,
|
||||
update_delta,
|
||||
)
|
||||
for key, value in update_delta.items():
|
||||
setattr(target_sub, key, value)
|
||||
subscriptions_updated += 1
|
||||
elif keep_subscription_uuid:
|
||||
final_end_date = now + (duplicate_expire_at - now)
|
||||
created_sub = await subscription_dal.upsert_subscription(
|
||||
session,
|
||||
{
|
||||
"user_id": int(existing_user.user_id),
|
||||
"panel_user_uuid": keep_panel_uuid,
|
||||
"panel_subscription_uuid": keep_subscription_uuid,
|
||||
"start_date": None,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": None,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE",
|
||||
"traffic_limit_bytes": getattr(settings, "user_traffic_limit_bytes", 0),
|
||||
"auto_renew_enabled": False,
|
||||
},
|
||||
)
|
||||
subscriptions_by_panel_uuid[keep_subscription_uuid] = created_sub
|
||||
active_subscriptions_by_user_panel[
|
||||
(int(created_sub.user_id), created_sub.panel_user_uuid)
|
||||
] = created_sub
|
||||
subscriptions_created += 1
|
||||
|
||||
duplicate_subscription_uuid = _panel_subscription_uuid(duplicate_panel_user)
|
||||
duplicate_sub = (
|
||||
subscriptions_by_panel_uuid.get(duplicate_subscription_uuid)
|
||||
if duplicate_subscription_uuid
|
||||
else None
|
||||
)
|
||||
if duplicate_sub and duplicate_sub is not target_sub:
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
duplicate_sub.subscription_id,
|
||||
{
|
||||
"user_id": int(existing_user.user_id),
|
||||
"is_active": False,
|
||||
"skip_notifications": True,
|
||||
"status_from_panel": "MERGED_PANEL_DUPLICATE",
|
||||
},
|
||||
)
|
||||
duplicate_sub.user_id = int(existing_user.user_id)
|
||||
duplicate_sub.is_active = False
|
||||
duplicate_sub.skip_notifications = True
|
||||
duplicate_sub.status_from_panel = "MERGED_PANEL_DUPLICATE"
|
||||
subscriptions_updated += 1
|
||||
elif not duplicate_sub:
|
||||
await session.execute(
|
||||
update(Subscription)
|
||||
.where(Subscription.panel_user_uuid == duplicate_panel_uuid)
|
||||
.values(
|
||||
user_id=int(existing_user.user_id),
|
||||
is_active=False,
|
||||
skip_notifications=True,
|
||||
status_from_panel="MERGED_PANEL_DUPLICATE",
|
||||
)
|
||||
)
|
||||
|
||||
if final_end_date:
|
||||
await panel_service.update_user_details_on_panel(
|
||||
keep_panel_uuid,
|
||||
_panel_identity_payload_with_expiry(existing_user, expire_at=final_end_date),
|
||||
log_response=False,
|
||||
)
|
||||
|
||||
deleted = await panel_service.delete_user_from_panel(
|
||||
duplicate_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
if deleted:
|
||||
logging.info(
|
||||
"Sync: absorbed duplicate panel UUID %s into kept panel UUID %s for user %s.",
|
||||
duplicate_panel_uuid,
|
||||
keep_panel_uuid,
|
||||
existing_user.user_id,
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"Sync: failed to delete duplicate panel UUID %s after absorbing it into %s.",
|
||||
duplicate_panel_uuid,
|
||||
keep_panel_uuid,
|
||||
)
|
||||
|
||||
return {
|
||||
"resolved": bool(deleted),
|
||||
"subscriptions_created": subscriptions_created,
|
||||
"subscriptions_updated": subscriptions_updated,
|
||||
}
|
||||
|
||||
|
||||
async def perform_sync(
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
@@ -383,6 +650,11 @@ async def _perform_sync_impl(
|
||||
subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"]
|
||||
active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"]
|
||||
panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"]
|
||||
panel_users_by_uuid = {
|
||||
str(panel_user["uuid"]): panel_user
|
||||
for panel_user in panel_users_data
|
||||
if panel_user.get("uuid")
|
||||
}
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
try:
|
||||
@@ -532,12 +804,61 @@ async def _perform_sync_impl(
|
||||
)
|
||||
if linked_uuid_still_present:
|
||||
is_duplicate_panel_identity = True
|
||||
(
|
||||
existing_user,
|
||||
can_absorb_duplicate_panel_user,
|
||||
) = await _merge_local_duplicate_panel_user_if_needed(
|
||||
session,
|
||||
existing_user=existing_user,
|
||||
duplicate_panel_uuid=panel_uuid,
|
||||
)
|
||||
if not can_absorb_duplicate_panel_user:
|
||||
logging.warning(
|
||||
"Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s because local duplicate merge failed.", # noqa: E501
|
||||
telegram_id_from_panel,
|
||||
linked_uuid,
|
||||
panel_uuid,
|
||||
)
|
||||
continue
|
||||
actual_user_id = existing_user.user_id
|
||||
users_by_panel_uuid[linked_uuid] = existing_user
|
||||
if existing_user.telegram_id is not None:
|
||||
users_by_telegram_id[int(existing_user.telegram_id)] = existing_user
|
||||
users_by_user_id[int(existing_user.user_id)] = existing_user
|
||||
if existing_user.email:
|
||||
users_by_email[existing_user.email.strip().lower()] = existing_user
|
||||
merge_result = await _absorb_duplicate_panel_identity(
|
||||
session,
|
||||
panel_service=panel_service,
|
||||
existing_user=existing_user,
|
||||
keep_panel_uuid=str(linked_uuid),
|
||||
keep_panel_user=panel_users_by_uuid.get(str(linked_uuid)),
|
||||
duplicate_panel_user=panel_user_dict,
|
||||
settings=settings,
|
||||
subscriptions_by_panel_uuid=subscriptions_by_panel_uuid,
|
||||
active_subscriptions_by_user_panel=(
|
||||
active_subscriptions_by_user_panel
|
||||
),
|
||||
)
|
||||
subscriptions_created += int(merge_result["subscriptions_created"])
|
||||
subscriptions_updated += int(merge_result["subscriptions_updated"])
|
||||
subscriptions_synced_count += int(
|
||||
merge_result["subscriptions_created"]
|
||||
) + int(merge_result["subscriptions_updated"])
|
||||
if merge_result["resolved"]:
|
||||
users_updated += 1
|
||||
users_uuid_updated += 1
|
||||
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
|
||||
str(panel_uuid)
|
||||
)
|
||||
users_by_panel_uuid.pop(str(panel_uuid), None)
|
||||
logging.warning(
|
||||
"Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s.", # noqa: E501
|
||||
"Sync: duplicate panel users share telegramId %s; kept local panel UUID %s and processed duplicate panel UUID %s.", # noqa: E501
|
||||
telegram_id_from_panel,
|
||||
linked_uuid,
|
||||
panel_uuid,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
@@ -589,28 +910,15 @@ async def _perform_sync_impl(
|
||||
if line
|
||||
)
|
||||
# Update description only when it differs from the current one on panel
|
||||
current_panel_description = (
|
||||
panel_user_dict.get("description") or ""
|
||||
).strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and not _description_matches(
|
||||
current_panel_description, desired_description
|
||||
if desired_description and not _panel_identity_matches_user(
|
||||
panel_user_dict,
|
||||
existing_user,
|
||||
desired_description,
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
"description": description_text,
|
||||
**(
|
||||
{"email": existing_user.email}
|
||||
if existing_user.email
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"telegramId": existing_user.telegram_id}
|
||||
if existing_user.telegram_id
|
||||
else {}
|
||||
),
|
||||
},
|
||||
_panel_identity_update_payload(existing_user, description_text),
|
||||
)
|
||||
except Exception as e_desc:
|
||||
logging.warning(
|
||||
|
||||
@@ -25,6 +25,7 @@ from .registry import (
|
||||
provider_emoji_map,
|
||||
provider_label_map,
|
||||
provider_telegram_button_text,
|
||||
provider_webhook_metadata,
|
||||
resolve_provider_presentation,
|
||||
)
|
||||
|
||||
@@ -53,5 +54,6 @@ __all__ = [
|
||||
"provider_telegram_button_text",
|
||||
"provider_emoji_map",
|
||||
"provider_label_map",
|
||||
"provider_webhook_metadata",
|
||||
"resolve_provider_presentation",
|
||||
]
|
||||
|
||||
@@ -333,6 +333,46 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
|
||||
return None
|
||||
|
||||
|
||||
def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]:
|
||||
if spec.webhook_path and spec.webhook_route:
|
||||
return spec
|
||||
if not spec.service_key:
|
||||
return None
|
||||
for candidate in PAYMENT_PROVIDER_SPECS:
|
||||
if (
|
||||
candidate.service_key == spec.service_key
|
||||
and candidate.webhook_path
|
||||
and candidate.webhook_route
|
||||
):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]:
|
||||
"""Return admin-manifest webhook metadata for a provider SPEC.
|
||||
|
||||
Some visible payment buttons share one backing service and webhook route
|
||||
(for example Platega SBP and Platega Crypto), so presentation-only specs
|
||||
inherit the route from their service sibling.
|
||||
"""
|
||||
webhook_spec = _webhook_spec_for(spec)
|
||||
if webhook_spec is None or not webhook_spec.webhook_path:
|
||||
return None
|
||||
try:
|
||||
path = str(webhook_spec.webhook_path(None) or "").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not path:
|
||||
return None
|
||||
return {
|
||||
"provider_id": spec.id,
|
||||
"provider_label": spec.label,
|
||||
"webhook_provider_id": webhook_spec.id,
|
||||
"webhook_path": path,
|
||||
"webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url),
|
||||
}
|
||||
|
||||
|
||||
def manifest_field_default(
|
||||
spec: PaymentProviderSpec,
|
||||
manifest_field: ProviderManifestField,
|
||||
|
||||
@@ -690,6 +690,51 @@ class NotificationService:
|
||||
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_account_merged(
|
||||
self,
|
||||
*,
|
||||
primary_user_id: int,
|
||||
removed_user_id: int,
|
||||
email: Optional[str],
|
||||
telegram_id: Optional[int],
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
final_end_date_text: Optional[str] = None,
|
||||
primary_panel_user_uuid: Optional[str] = None,
|
||||
removed_panel_user_uuid: Optional[str] = None,
|
||||
):
|
||||
"""Send notification when duplicate email/Telegram accounts are merged."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
display_user_id = int(telegram_id or primary_user_id)
|
||||
user_display = self._format_user_display(
|
||||
user_id=display_user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_account_merged",
|
||||
primary_user_id=primary_user_id,
|
||||
removed_user_id=removed_user_id,
|
||||
telegram_id=telegram_id or "",
|
||||
user_display=user_display,
|
||||
email=hd.quote(email or ""),
|
||||
final_end_date=hd.quote(final_end_date_text or ""),
|
||||
primary_panel_user_uuid=hd.quote(primary_panel_user_uuid or ""),
|
||||
removed_panel_user_uuid=hd.quote(removed_panel_user_uuid or ""),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
profile_keyboard = (
|
||||
self._build_profile_keyboard(_, int(telegram_id)) if telegram_id else None
|
||||
)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
def _format_traffic_gb_admin(self, traffic_gb: float) -> str:
|
||||
value = float(traffic_gb)
|
||||
if value.is_integer():
|
||||
|
||||
@@ -155,6 +155,13 @@ class TariffTrafficWorker:
|
||||
cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid))
|
||||
if cached_panel_user is not None:
|
||||
return cached_panel_user
|
||||
return await self._repair_missing_panel_user_for_subscription(
|
||||
session,
|
||||
sub,
|
||||
panel_users_by_uuid=panel_users_by_uuid,
|
||||
semaphore=semaphore,
|
||||
confirmed_missing=True,
|
||||
)
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
@@ -167,12 +174,22 @@ class TariffTrafficWorker:
|
||||
sub.panel_user_uuid,
|
||||
)
|
||||
return {}
|
||||
return data or {}
|
||||
if data:
|
||||
return data
|
||||
return await self._repair_missing_panel_user_for_subscription(
|
||||
session,
|
||||
sub,
|
||||
panel_users_by_uuid=None,
|
||||
semaphore=semaphore,
|
||||
confirmed_missing=False,
|
||||
)
|
||||
|
||||
for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE):
|
||||
chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE]
|
||||
panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk))
|
||||
for sub, panel_data in zip(chunk, panel_payloads):
|
||||
if not panel_data:
|
||||
continue
|
||||
try:
|
||||
tariff = self.settings.tariffs_config.require(sub.tariff_key)
|
||||
except Exception:
|
||||
@@ -255,6 +272,69 @@ class TariffTrafficWorker:
|
||||
)
|
||||
return by_uuid
|
||||
|
||||
async def _repair_missing_panel_user_for_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
panel_users_by_uuid: Optional[dict[str, dict]],
|
||||
semaphore: asyncio.Semaphore,
|
||||
confirmed_missing: bool,
|
||||
) -> dict:
|
||||
current_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
|
||||
try:
|
||||
user_id = int(sub.user_id)
|
||||
except (TypeError, ValueError):
|
||||
user_id = 0
|
||||
db_user = await user_dal.get_user_by_id(session, user_id) if user_id else None
|
||||
canonical_uuid = str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||
|
||||
if canonical_uuid and canonical_uuid != current_uuid:
|
||||
panel_user = None
|
||||
if panel_users_by_uuid is not None:
|
||||
panel_user = panel_users_by_uuid.get(canonical_uuid)
|
||||
else:
|
||||
async with semaphore:
|
||||
try:
|
||||
panel_user = await self.panel_service.get_user_by_uuid(
|
||||
canonical_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"TariffTrafficWorker: failed to fetch canonical panel user %s",
|
||||
canonical_uuid,
|
||||
)
|
||||
panel_user = None
|
||||
if panel_user:
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: repaired subscription %s panel UUID %s -> %s",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
canonical_uuid,
|
||||
)
|
||||
sub.panel_user_uuid = canonical_uuid
|
||||
return panel_user
|
||||
|
||||
if confirmed_missing:
|
||||
sub.is_active = False
|
||||
sub.skip_notifications = True
|
||||
sub.status_from_panel = "PANEL_USER_NOT_FOUND"
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: deactivated subscription %s because panel user %s "
|
||||
"is missing",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: skipping subscription %s because panel user %s "
|
||||
"could not be fetched",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _ensure_period_reset_strategy(
|
||||
self,
|
||||
sub: Subscription,
|
||||
|
||||
Reference in New Issue
Block a user