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:
3252a8
2026-05-22 22:57:24 +03:00
31 changed files with 2093 additions and 219 deletions
@@ -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"] = [
{
+33 -38
View File
@@ -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
+48 -23
View File
@@ -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:
+143 -2
View File
@@ -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:
+23 -5
View File
@@ -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)
+329 -21
View File
@@ -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",
]
+40
View File
@@ -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():
+81 -1
View File
@@ -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,